@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 +29 -0
- package/dist/http-request.d.ts +160 -20
- package/dist/http-request.js +245 -161
- package/package.json +3 -3
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
|
package/dist/http-request.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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.
|
package/dist/http-request.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
reject(new Error(`Download timed out after ${timeout}ms`));
|
|
246
|
+
res.on("error", (error) => {
|
|
247
|
+
fileStream.close();
|
|
248
|
+
reject(error);
|
|
262
249
|
});
|
|
263
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
462
|
+
resolveOnce(response);
|
|
416
463
|
});
|
|
417
464
|
res.on("error", (error) => {
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
-
|
|
515
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|