@socketsecurity/lib 5.12.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 +32 -0
- package/dist/http-request.d.ts +151 -4
- package/dist/http-request.js +189 -35
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ 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
|
+
|
|
26
|
+
## [5.13.0](https://github.com/SocketDev/socket-lib/releases/tag/v5.13.0) - 2026-04-05
|
|
27
|
+
|
|
28
|
+
### Added — http-request
|
|
29
|
+
|
|
30
|
+
- `readIncomingResponse()` — reads and buffers a Node.js `IncomingResponse` into an `HttpResponse` (#143)
|
|
31
|
+
- Useful for converting raw responses from code that bypasses `httpRequest()` (e.g. multipart form-data uploads) into the standard `HttpResponse` interface
|
|
32
|
+
- `IncomingResponse` type alias — disambiguates `IncomingMessage` as a client-side response
|
|
33
|
+
- `IncomingRequest` type alias — disambiguates `IncomingMessage` as a server-side request
|
|
34
|
+
|
|
35
|
+
### Changed — http-request
|
|
36
|
+
|
|
37
|
+
- Internal `httpRequestAttempt` callbacks now use `IncomingResponse` type
|
|
38
|
+
- `HttpResponse.rawResponse` type narrowed from `IncomingMessage` to `IncomingResponse`
|
|
39
|
+
|
|
8
40
|
## [5.12.0](https://github.com/SocketDev/socket-lib/releases/tag/v5.12.0) - 2026-04-04
|
|
9
41
|
|
|
10
42
|
### Added — http-request
|
package/dist/http-request.d.ts
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
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';
|
|
18
|
+
/** IncomingMessage received as a response to a client request (http.request callback). */
|
|
19
|
+
export type IncomingResponse = IncomingMessage;
|
|
20
|
+
/** IncomingMessage received as a request in a server handler (http.createServer callback). */
|
|
21
|
+
export type IncomingRequest = IncomingMessage;
|
|
2
22
|
import type { Logger } from './logger.js';
|
|
3
23
|
/**
|
|
4
24
|
* Information passed to the onRequest hook before each request attempt.
|
|
@@ -35,7 +55,17 @@ export interface HttpHooks {
|
|
|
35
55
|
export interface HttpRequestOptions {
|
|
36
56
|
/**
|
|
37
57
|
* Request body to send.
|
|
38
|
-
* Can be a string
|
|
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.
|
|
39
69
|
*
|
|
40
70
|
* @example
|
|
41
71
|
* ```ts
|
|
@@ -52,9 +82,18 @@ export interface HttpRequestOptions {
|
|
|
52
82
|
* method: 'POST',
|
|
53
83
|
* body: buffer
|
|
54
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
|
+
* })
|
|
55
94
|
* ```
|
|
56
95
|
*/
|
|
57
|
-
body?: Buffer | string | undefined;
|
|
96
|
+
body?: Buffer | Readable | string | undefined;
|
|
58
97
|
/**
|
|
59
98
|
* Custom CA certificates for TLS connections.
|
|
60
99
|
* When provided, these certificates are combined with the default trust
|
|
@@ -158,6 +197,38 @@ export interface HttpRequestOptions {
|
|
|
158
197
|
* ```
|
|
159
198
|
*/
|
|
160
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;
|
|
161
232
|
/**
|
|
162
233
|
* Number of retry attempts for failed requests.
|
|
163
234
|
* Uses exponential backoff: delay = `retryDelay` * 2^attempt.
|
|
@@ -190,6 +261,23 @@ export interface HttpRequestOptions {
|
|
|
190
261
|
* ```
|
|
191
262
|
*/
|
|
192
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;
|
|
193
281
|
/**
|
|
194
282
|
* Request timeout in milliseconds.
|
|
195
283
|
* If the request takes longer than this, it will be aborted.
|
|
@@ -314,12 +402,71 @@ export interface HttpResponse {
|
|
|
314
402
|
*/
|
|
315
403
|
text(): string;
|
|
316
404
|
/**
|
|
317
|
-
* The underlying Node.js
|
|
405
|
+
* The underlying Node.js IncomingResponse for advanced use cases
|
|
318
406
|
* (e.g., streaming, custom header inspection). Only available when
|
|
319
407
|
* the response was not consumed by the convenience methods.
|
|
320
408
|
*/
|
|
321
|
-
rawResponse?:
|
|
409
|
+
rawResponse?: IncomingResponse | undefined;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Read and buffer a client-side IncomingResponse into an HttpResponse.
|
|
413
|
+
*
|
|
414
|
+
* Useful when you have a raw response from code that bypasses
|
|
415
|
+
* `httpRequest()` (e.g., multipart form-data uploads via `http.request()`,
|
|
416
|
+
* or responses from third-party HTTP libraries) and need to convert it
|
|
417
|
+
* into the standard HttpResponse interface.
|
|
418
|
+
*/
|
|
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);
|
|
322
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>;
|
|
323
470
|
/**
|
|
324
471
|
* Configuration options for file downloads.
|
|
325
472
|
*/
|
package/dist/http-request.js
CHANGED
|
@@ -19,13 +19,17 @@ 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,
|
|
25
26
|
httpJson: () => httpJson,
|
|
26
27
|
httpRequest: () => httpRequest,
|
|
27
28
|
httpText: () => httpText,
|
|
28
|
-
parseChecksums: () => parseChecksums
|
|
29
|
+
parseChecksums: () => parseChecksums,
|
|
30
|
+
parseRetryAfterHeader: () => parseRetryAfterHeader,
|
|
31
|
+
readIncomingResponse: () => readIncomingResponse,
|
|
32
|
+
sanitizeHeaders: () => sanitizeHeaders
|
|
29
33
|
});
|
|
30
34
|
module.exports = __toCommonJS(http_request_exports);
|
|
31
35
|
var import_fs = require("./fs.js");
|
|
@@ -61,6 +65,87 @@ function getHttps() {
|
|
|
61
65
|
}
|
|
62
66
|
return _https;
|
|
63
67
|
}
|
|
68
|
+
async function readIncomingResponse(msg) {
|
|
69
|
+
const chunks = [];
|
|
70
|
+
for await (const chunk of msg) {
|
|
71
|
+
chunks.push(chunk);
|
|
72
|
+
}
|
|
73
|
+
const body = Buffer.concat(chunks);
|
|
74
|
+
const status = msg.statusCode ?? 0;
|
|
75
|
+
const statusText = msg.statusMessage ?? "";
|
|
76
|
+
return {
|
|
77
|
+
arrayBuffer: () => body.buffer.slice(
|
|
78
|
+
body.byteOffset,
|
|
79
|
+
body.byteOffset + body.byteLength
|
|
80
|
+
),
|
|
81
|
+
body,
|
|
82
|
+
headers: msg.headers,
|
|
83
|
+
json: () => JSON.parse(body.toString("utf8")),
|
|
84
|
+
ok: status >= 200 && status < 300,
|
|
85
|
+
rawResponse: msg,
|
|
86
|
+
status,
|
|
87
|
+
statusText,
|
|
88
|
+
text: () => body.toString("utf8")
|
|
89
|
+
};
|
|
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
|
+
}
|
|
64
149
|
function parseChecksums(text) {
|
|
65
150
|
const checksums = { __proto__: null };
|
|
66
151
|
for (const line of text.split("\n")) {
|
|
@@ -273,12 +358,34 @@ async function httpRequestAttempt(url, options) {
|
|
|
273
358
|
timeout = 3e4
|
|
274
359
|
} = { __proto__: null, ...options };
|
|
275
360
|
const startTime = Date.now();
|
|
361
|
+
const streamHeaders = body && typeof body === "object" && "getHeaders" in body && typeof body.getHeaders === "function" ? body.getHeaders() : void 0;
|
|
276
362
|
const mergedHeaders = {
|
|
277
363
|
"User-Agent": "socket-registry/1.0",
|
|
364
|
+
...streamHeaders,
|
|
278
365
|
...headers
|
|
279
366
|
};
|
|
280
367
|
hooks?.onRequest?.({ method, url, headers: mergedHeaders, timeout });
|
|
281
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
|
+
};
|
|
282
389
|
const parsedUrl = new URL(url);
|
|
283
390
|
const isHttps = parsedUrl.protocol === "https:";
|
|
284
391
|
const httpModule = isHttps ? /* @__PURE__ */ getHttps() : /* @__PURE__ */ getHttp();
|
|
@@ -294,23 +401,28 @@ async function httpRequestAttempt(url, options) {
|
|
|
294
401
|
requestOptions["ca"] = ca;
|
|
295
402
|
}
|
|
296
403
|
const emitResponse = (info) => {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
404
|
+
try {
|
|
405
|
+
hooks?.onResponse?.({
|
|
406
|
+
duration: Date.now() - startTime,
|
|
407
|
+
method,
|
|
408
|
+
url,
|
|
409
|
+
...info
|
|
410
|
+
});
|
|
411
|
+
} catch {
|
|
412
|
+
}
|
|
303
413
|
};
|
|
304
414
|
const request = httpModule.request(
|
|
305
415
|
requestOptions,
|
|
306
416
|
(res) => {
|
|
307
417
|
if (followRedirects && res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
418
|
+
res.resume();
|
|
308
419
|
emitResponse({
|
|
309
420
|
headers: res.headers,
|
|
310
421
|
status: res.statusCode,
|
|
311
422
|
statusText: res.statusMessage
|
|
312
423
|
});
|
|
313
424
|
if (maxRedirects <= 0) {
|
|
425
|
+
settled = true;
|
|
314
426
|
reject(
|
|
315
427
|
new Error(
|
|
316
428
|
`Too many redirects (exceeded maximum: ${maxRedirects})`
|
|
@@ -321,6 +433,7 @@ async function httpRequestAttempt(url, options) {
|
|
|
321
433
|
const redirectUrl = res.headers.location.startsWith("http") ? res.headers.location : new URL(res.headers.location, url).toString();
|
|
322
434
|
const redirectParsed = new URL(redirectUrl);
|
|
323
435
|
if (isHttps && redirectParsed.protocol !== "https:") {
|
|
436
|
+
settled = true;
|
|
324
437
|
reject(
|
|
325
438
|
new Error(
|
|
326
439
|
`Redirect from HTTPS to HTTP is not allowed: ${redirectUrl}`
|
|
@@ -328,6 +441,7 @@ async function httpRequestAttempt(url, options) {
|
|
|
328
441
|
);
|
|
329
442
|
return;
|
|
330
443
|
}
|
|
444
|
+
settled = true;
|
|
331
445
|
resolve(
|
|
332
446
|
httpRequestAttempt(redirectUrl, {
|
|
333
447
|
body,
|
|
@@ -349,18 +463,22 @@ async function httpRequestAttempt(url, options) {
|
|
|
349
463
|
totalBytes += chunk.length;
|
|
350
464
|
if (maxResponseSize && totalBytes > maxResponseSize) {
|
|
351
465
|
res.destroy();
|
|
466
|
+
request.destroy();
|
|
352
467
|
const sizeMB = (totalBytes / (1024 * 1024)).toFixed(2);
|
|
353
468
|
const maxMB = (maxResponseSize / (1024 * 1024)).toFixed(2);
|
|
354
|
-
|
|
355
|
-
|
|
469
|
+
rejectOnce(
|
|
470
|
+
new Error(
|
|
471
|
+
`Response exceeds maximum size limit (${sizeMB}MB > ${maxMB}MB)`
|
|
472
|
+
)
|
|
356
473
|
);
|
|
357
|
-
emitResponse({ error: err });
|
|
358
|
-
reject(err);
|
|
359
474
|
return;
|
|
360
475
|
}
|
|
361
476
|
chunks.push(chunk);
|
|
362
477
|
});
|
|
363
478
|
res.on("end", () => {
|
|
479
|
+
if (settled) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
364
482
|
const responseBody = Buffer.concat(chunks);
|
|
365
483
|
const ok = res.statusCode !== void 0 && res.statusCode >= 200 && res.statusCode < 300;
|
|
366
484
|
const response = {
|
|
@@ -388,11 +506,10 @@ async function httpRequestAttempt(url, options) {
|
|
|
388
506
|
status: res.statusCode,
|
|
389
507
|
statusText: res.statusMessage
|
|
390
508
|
});
|
|
391
|
-
|
|
509
|
+
resolveOnce(response);
|
|
392
510
|
});
|
|
393
511
|
res.on("error", (error) => {
|
|
394
|
-
|
|
395
|
-
reject(error);
|
|
512
|
+
rejectOnce(error);
|
|
396
513
|
});
|
|
397
514
|
}
|
|
398
515
|
);
|
|
@@ -402,24 +519,33 @@ async function httpRequestAttempt(url, options) {
|
|
|
402
519
|
method,
|
|
403
520
|
error
|
|
404
521
|
);
|
|
405
|
-
|
|
406
|
-
emitResponse({ error: enhanced });
|
|
407
|
-
reject(enhanced);
|
|
522
|
+
rejectOnce(new Error(message, { cause: error }));
|
|
408
523
|
});
|
|
409
524
|
request.on("timeout", () => {
|
|
410
525
|
request.destroy();
|
|
411
|
-
|
|
412
|
-
|
|
526
|
+
rejectOnce(
|
|
527
|
+
new Error(
|
|
528
|
+
`${method} request timed out after ${timeout}ms: ${url}
|
|
413
529
|
\u2192 Server did not respond in time.
|
|
414
530
|
\u2192 Try: Increase timeout or check network connectivity.`
|
|
531
|
+
)
|
|
415
532
|
);
|
|
416
|
-
emitResponse({ error: err });
|
|
417
|
-
reject(err);
|
|
418
533
|
});
|
|
419
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
|
+
}
|
|
420
544
|
request.write(body);
|
|
545
|
+
request.end();
|
|
546
|
+
} else {
|
|
547
|
+
request.end();
|
|
421
548
|
}
|
|
422
|
-
request.end();
|
|
423
549
|
});
|
|
424
550
|
}
|
|
425
551
|
async function httpDownload(url, destPath, options) {
|
|
@@ -547,31 +673,55 @@ async function httpRequest(url, options) {
|
|
|
547
673
|
maxRedirects = 5,
|
|
548
674
|
maxResponseSize,
|
|
549
675
|
method = "GET",
|
|
676
|
+
onRetry,
|
|
550
677
|
retries = 0,
|
|
551
678
|
retryDelay = 1e3,
|
|
679
|
+
throwOnError = false,
|
|
552
680
|
timeout = 3e4
|
|
553
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
|
+
};
|
|
554
701
|
let lastError;
|
|
555
702
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
556
703
|
try {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
hooks,
|
|
563
|
-
maxRedirects,
|
|
564
|
-
maxResponseSize,
|
|
565
|
-
method,
|
|
566
|
-
timeout
|
|
567
|
-
});
|
|
704
|
+
const response = await httpRequestAttempt(url, attemptOpts);
|
|
705
|
+
if (throwOnError && !response.ok) {
|
|
706
|
+
throw new HttpResponseError(response);
|
|
707
|
+
}
|
|
708
|
+
return response;
|
|
568
709
|
} catch (e) {
|
|
569
710
|
lastError = e;
|
|
570
711
|
if (attempt === retries) {
|
|
571
712
|
break;
|
|
572
713
|
}
|
|
573
714
|
const delayMs = retryDelay * 2 ** attempt;
|
|
574
|
-
|
|
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
|
+
}
|
|
575
725
|
}
|
|
576
726
|
}
|
|
577
727
|
throw lastError || new Error("Request failed after retries");
|
|
@@ -607,11 +757,15 @@ async function httpText(url, options) {
|
|
|
607
757
|
}
|
|
608
758
|
// Annotate the CommonJS export names for ESM import in node:
|
|
609
759
|
0 && (module.exports = {
|
|
760
|
+
HttpResponseError,
|
|
610
761
|
enrichErrorMessage,
|
|
611
762
|
fetchChecksums,
|
|
612
763
|
httpDownload,
|
|
613
764
|
httpJson,
|
|
614
765
|
httpRequest,
|
|
615
766
|
httpText,
|
|
616
|
-
parseChecksums
|
|
767
|
+
parseChecksums,
|
|
768
|
+
parseRetryAfterHeader,
|
|
769
|
+
readIncomingResponse,
|
|
770
|
+
sanitizeHeaders
|
|
617
771
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@socketsecurity/lib",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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",
|