nstarter-http-request 0.1.11 → 0.1.13
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 +6 -0
- package/README.md +1 -2
- package/dist/cjs/adapter/axios.adapter.js +97 -2
- package/dist/cjs/adapter/axios.adapter.js.map +1 -1
- package/dist/cjs/adapter/base.js +20 -1
- package/dist/cjs/adapter/base.js.map +1 -1
- package/dist/cjs/adapter/undici.adapter.js +97 -77
- package/dist/cjs/adapter/undici.adapter.js.map +1 -1
- package/dist/cjs/client.js +99 -56
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/config.js +1 -1
- package/dist/cjs/config.js.map +1 -1
- package/dist/cjs/const/config.const.js +8 -3
- package/dist/cjs/const/config.const.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/types/errors.js +30 -2
- package/dist/cjs/types/errors.js.map +1 -1
- package/dist/cjs/utils/index.js +2 -1
- package/dist/cjs/utils/index.js.map +1 -1
- package/dist/cjs/utils/redirect.js +138 -0
- package/dist/cjs/utils/redirect.js.map +1 -0
- package/dist/esm/adapter/axios.adapter.js +99 -4
- package/dist/esm/adapter/axios.adapter.js.map +1 -1
- package/dist/esm/adapter/base.js +20 -1
- package/dist/esm/adapter/base.js.map +1 -1
- package/dist/esm/adapter/undici.adapter.js +98 -78
- package/dist/esm/adapter/undici.adapter.js.map +1 -1
- package/dist/esm/client.js +100 -57
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/config.js +1 -1
- package/dist/esm/config.js.map +1 -1
- package/dist/esm/const/config.const.js +7 -2
- package/dist/esm/const/config.const.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/esm/types/errors.js +28 -1
- package/dist/esm/types/errors.js.map +1 -1
- package/dist/esm/utils/index.js +1 -0
- package/dist/esm/utils/index.js.map +1 -1
- package/dist/esm/utils/redirect.js +133 -0
- package/dist/esm/utils/redirect.js.map +1 -0
- package/dist/types/adapter/axios.adapter.d.ts +22 -0
- package/dist/types/adapter/axios.adapter.d.ts.map +1 -1
- package/dist/types/adapter/base.d.ts +6 -0
- package/dist/types/adapter/base.d.ts.map +1 -1
- package/dist/types/adapter/undici.adapter.d.ts +24 -12
- package/dist/types/adapter/undici.adapter.d.ts.map +1 -1
- package/dist/types/client.d.ts +11 -0
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/const/config.const.d.ts +6 -0
- package/dist/types/const/config.const.d.ts.map +1 -1
- package/dist/types/types/config.d.ts +2 -2
- package/dist/types/types/config.d.ts.map +1 -1
- package/dist/types/types/errors.d.ts +26 -1
- package/dist/types/types/errors.d.ts.map +1 -1
- package/dist/types/types/request_response.d.ts +1 -1
- package/dist/types/types/request_response.d.ts.map +1 -1
- package/dist/types/utils/index.d.ts +1 -0
- package/dist/types/utils/index.d.ts.map +1 -1
- package/dist/types/utils/redirect.d.ts +26 -0
- package/dist/types/utils/redirect.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/adapter/axios.adapter.ts +128 -4
- package/src/adapter/base.ts +22 -1
- package/src/adapter/undici.adapter.ts +112 -91
- package/src/client.ts +114 -63
- package/src/config.ts +1 -1
- package/src/const/config.const.ts +8 -2
- package/src/types/config.ts +2 -3
- package/src/types/errors.ts +39 -1
- package/src/types/request_response.ts +1 -2
- package/src/utils/index.ts +1 -0
- package/src/utils/redirect.ts +163 -0
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
6
|
HttpRequestOptions,
|
|
7
|
-
HttpResponse
|
|
8
|
-
|
|
7
|
+
HttpResponse
|
|
8
|
+
} from '../types';
|
|
9
|
+
import {
|
|
10
|
+
HttpError,
|
|
11
|
+
ResponseTooLargeError
|
|
9
12
|
} from '../types';
|
|
10
|
-
import { HttpError } from '../types';
|
|
11
13
|
import { BaseHttpAdapter } from './base';
|
|
12
14
|
import undici, {
|
|
13
15
|
Agent,
|
|
@@ -16,6 +18,7 @@ import undici, {
|
|
|
16
18
|
} from 'undici';
|
|
17
19
|
import { STATUS_CODES } from 'http';
|
|
18
20
|
import { EClientAdapter } from '../const';
|
|
21
|
+
import { RedirectUtils } from '../utils';
|
|
19
22
|
|
|
20
23
|
interface RequestTimeoutState {
|
|
21
24
|
signal?: AbortSignal;
|
|
@@ -50,7 +53,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
50
53
|
const headers = this.prepareHeaders(options.headers);
|
|
51
54
|
const body = this.prepareBody(options.body, headers);
|
|
52
55
|
const timeoutState = this.createRequestTimeoutState(options);
|
|
53
|
-
const dispatcher = this.buildDispatcher(options
|
|
56
|
+
const dispatcher = this.buildDispatcher(options);
|
|
54
57
|
const undiciOptions = this.buildUndiciOptions(options, headers, body, dispatcher, timeoutState);
|
|
55
58
|
|
|
56
59
|
try {
|
|
@@ -69,7 +72,8 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
69
72
|
timeoutState.cleanup();
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
// 读取过程中会按 maxResponseBodySize 提前拒绝/中断,超限时不会把完整响应体读入内存
|
|
76
|
+
const data = await this.readResponseBody<T>(response, options);
|
|
73
77
|
|
|
74
78
|
const httpResponse: HttpResponse<T> = {
|
|
75
79
|
status: response.statusCode,
|
|
@@ -79,7 +83,6 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
79
83
|
config: options
|
|
80
84
|
};
|
|
81
85
|
|
|
82
|
-
this.ensureResponseBodySize(httpResponse, options.responseType);
|
|
83
86
|
this.ensureSuccessStatus(httpResponse);
|
|
84
87
|
|
|
85
88
|
return httpResponse;
|
|
@@ -96,6 +99,8 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
96
99
|
throw this.handleUndiciError(error, options);
|
|
97
100
|
} finally {
|
|
98
101
|
timeoutState.cleanup();
|
|
102
|
+
// buildDispatcher 为每个请求单独创建 Agent/ProxyAgent,用完需要关闭,否则连接池与 keep-alive 连接会一直保留。
|
|
103
|
+
dispatcher?.close().catch(() => undefined);
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
106
|
|
|
@@ -153,15 +158,15 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
153
158
|
/**
|
|
154
159
|
* 构建 Dispatcher(代理或 TLS 配置)
|
|
155
160
|
* @param options 请求选项
|
|
156
|
-
* @param timeout 请求总超时时间
|
|
157
161
|
*/
|
|
158
|
-
private buildDispatcher(options: HttpRequestOptions
|
|
162
|
+
private buildDispatcher(options: HttpRequestOptions): Dispatcher | undefined {
|
|
159
163
|
let dispatcher: Dispatcher | undefined;
|
|
164
|
+
const timeout = this.resolveUndiciTimeout(options);
|
|
160
165
|
|
|
161
166
|
// 如果 rejectUnauthorized 为 false,需要创建 Agent 来设置 TLS 选项
|
|
162
167
|
if (!this.securityConfig.rejectUnauthorized) {
|
|
163
168
|
dispatcher = new Agent({
|
|
164
|
-
|
|
169
|
+
connectTimeout: timeout,
|
|
165
170
|
connect: {
|
|
166
171
|
rejectUnauthorized: false
|
|
167
172
|
}
|
|
@@ -198,7 +203,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
198
203
|
|
|
199
204
|
const agent = new ProxyAgent({
|
|
200
205
|
uri: proxyUrl,
|
|
201
|
-
|
|
206
|
+
connectTimeout: timeout,
|
|
202
207
|
connect: {
|
|
203
208
|
rejectUnauthorized: this.securityConfig.rejectUnauthorized
|
|
204
209
|
}
|
|
@@ -222,7 +227,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
222
227
|
private createForwardProxyAgent(proxyUrl: string, timeout?: number): ProxyAgent {
|
|
223
228
|
const agent = new ProxyAgent({
|
|
224
229
|
uri: proxyUrl,
|
|
225
|
-
|
|
230
|
+
connectTimeout: timeout,
|
|
226
231
|
connect: {
|
|
227
232
|
rejectUnauthorized: this.securityConfig.rejectUnauthorized
|
|
228
233
|
}
|
|
@@ -246,7 +251,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
246
251
|
dispatcher?: Dispatcher,
|
|
247
252
|
timeoutState?: RequestTimeoutState
|
|
248
253
|
): Parameters<typeof undici.request>[1] {
|
|
249
|
-
const timeout = this.
|
|
254
|
+
const timeout = this.resolveUndiciTimeout(options);
|
|
250
255
|
return {
|
|
251
256
|
method: options.method as Dispatcher.HttpMethod,
|
|
252
257
|
headers,
|
|
@@ -267,6 +272,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
267
272
|
const timeout = this.resolveTimeout(options);
|
|
268
273
|
if (!this.isPositiveTimeout(timeout)) {
|
|
269
274
|
return {
|
|
275
|
+
timeout,
|
|
270
276
|
timedOut: false,
|
|
271
277
|
cleanup: () => {}
|
|
272
278
|
};
|
|
@@ -293,10 +299,18 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
293
299
|
/**
|
|
294
300
|
* 获取请求级超时时间。
|
|
295
301
|
*/
|
|
296
|
-
private resolveTimeout(options: HttpRequestOptions): number {
|
|
302
|
+
private resolveTimeout(options: HttpRequestOptions): number | undefined {
|
|
297
303
|
return options.timeout ?? this.requestConfig.timeout;
|
|
298
304
|
}
|
|
299
305
|
|
|
306
|
+
/**
|
|
307
|
+
* 获取传给 undici 的阶段超时时间。0 用于内部禁用 undici 默认阶段超时。
|
|
308
|
+
*/
|
|
309
|
+
private resolveUndiciTimeout(options: HttpRequestOptions): number {
|
|
310
|
+
const timeout = this.resolveTimeout(options);
|
|
311
|
+
return this.isPositiveTimeout(timeout) ? timeout : 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
300
314
|
/**
|
|
301
315
|
* 判断超时时间是否启用。
|
|
302
316
|
*/
|
|
@@ -329,10 +343,14 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
329
343
|
}
|
|
330
344
|
|
|
331
345
|
/**
|
|
332
|
-
*
|
|
346
|
+
* 2xx 和 304 作为成功结果返回。undici 未跟跳的 3xx 交回调用方。
|
|
333
347
|
*/
|
|
334
348
|
private ensureSuccessStatus(response: HttpResponse): void {
|
|
335
|
-
if (
|
|
349
|
+
if (
|
|
350
|
+
(response.status >= 200 && response.status < 300)
|
|
351
|
+
|| response.status === 304
|
|
352
|
+
|| RedirectUtils.isRedirectStatus(response.status)
|
|
353
|
+
) {
|
|
336
354
|
return;
|
|
337
355
|
}
|
|
338
356
|
|
|
@@ -346,119 +364,122 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
346
364
|
}
|
|
347
365
|
|
|
348
366
|
/**
|
|
349
|
-
*
|
|
367
|
+
* 解析 Content-Length 响应头。仅接受合法的非负整数,重复头/非法值视为未知。
|
|
350
368
|
*/
|
|
351
|
-
private
|
|
352
|
-
if (
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
const maxResponseBodySize = this.requestConfig.maxResponseBodySize;
|
|
356
|
-
if (
|
|
357
|
-
typeof maxResponseBodySize !== 'number' ||
|
|
358
|
-
!Number.isFinite(maxResponseBodySize) ||
|
|
359
|
-
maxResponseBodySize <= 0
|
|
360
|
-
) {
|
|
361
|
-
// NsHttpClient 会先做配置合并和校验;这里防御直接实例化 Adapter 的异常配置。
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
const bodySize = this.getBodySize(response.data);
|
|
365
|
-
if (bodySize <= maxResponseBodySize) {
|
|
366
|
-
return;
|
|
369
|
+
private parseContentLength(value: string | string[] | undefined): number | undefined {
|
|
370
|
+
if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) {
|
|
371
|
+
return undefined;
|
|
367
372
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
'ERR_BAD_RESPONSE',
|
|
371
|
-
response.status,
|
|
372
|
-
response,
|
|
373
|
-
response.config
|
|
374
|
-
);
|
|
373
|
+
const length = Number(value.trim());
|
|
374
|
+
return Number.isSafeInteger(length) ? length : undefined;
|
|
375
375
|
}
|
|
376
376
|
|
|
377
377
|
/**
|
|
378
|
-
*
|
|
378
|
+
* 丢弃未读完的响应体并终止下载。
|
|
379
|
+
* undici 的 BodyReadable.destroy() 不带错误时会以 RequestAbortedError 触发 error 事件,
|
|
380
|
+
* 没有监听器会变成未捕获异常,因此先挂空监听器。
|
|
379
381
|
*/
|
|
380
|
-
private
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
382
|
+
private discardBody(response: Dispatcher.ResponseData): void {
|
|
383
|
+
response.body.on('error', () => undefined);
|
|
384
|
+
response.body.destroy();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* 带大小上限地读取非流式响应体。
|
|
389
|
+
*
|
|
390
|
+
* 1. Content-Length 预检:响应头已声明超限时,不读取响应体,直接销毁并拒绝。
|
|
391
|
+
* 2. 逐分块累计字节数,累计值超过上限(等于上限放行)立即销毁响应流并拒绝,后续数据不再下载;
|
|
392
|
+
* 该行为对齐 axios(Node http 适配器)的 maxContentLength。
|
|
393
|
+
*/
|
|
394
|
+
private async readBodyWithLimit(response: Dispatcher.ResponseData, options: HttpRequestOptions): Promise<Buffer> {
|
|
395
|
+
const maxRespBodySize = this.resolveMaxResponseBodySize();
|
|
396
|
+
|
|
397
|
+
// HEAD / 204 / 304 没有响应体,其 Content-Length 描述的是对应实体而不是本次响应体:
|
|
398
|
+
// 不能用于预检;服务端携带时 undici 还会一直等待并不存在的响应体,因此不进入读取,直接丢弃。
|
|
399
|
+
if (options.method === 'HEAD' || response.statusCode === 204 || response.statusCode === 304) {
|
|
400
|
+
this.discardBody(response);
|
|
401
|
+
return Buffer.alloc(0);
|
|
392
402
|
}
|
|
393
|
-
|
|
394
|
-
|
|
403
|
+
|
|
404
|
+
if (maxRespBodySize !== undefined) {
|
|
405
|
+
const contentLength = this.parseContentLength(response.headers['content-length']);
|
|
406
|
+
if (contentLength !== undefined && contentLength > maxRespBodySize) {
|
|
407
|
+
this.discardBody(response);
|
|
408
|
+
throw new ResponseTooLargeError(maxRespBodySize, options, response.statusCode);
|
|
409
|
+
}
|
|
395
410
|
}
|
|
396
|
-
|
|
397
|
-
|
|
411
|
+
|
|
412
|
+
const chunks: Buffer[] = [];
|
|
413
|
+
let total = 0;
|
|
414
|
+
for await (const chunk of response.body) {
|
|
415
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
416
|
+
total += buffer.byteLength;
|
|
417
|
+
if (maxRespBodySize !== undefined && total > maxRespBodySize) {
|
|
418
|
+
this.discardBody(response);
|
|
419
|
+
throw new ResponseTooLargeError(maxRespBodySize, options, response.statusCode);
|
|
420
|
+
}
|
|
421
|
+
chunks.push(buffer);
|
|
398
422
|
}
|
|
399
|
-
return Buffer.
|
|
423
|
+
return Buffer.concat(chunks, total);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* 基于已读取(已受大小限制)的字节构造 Response,复用运行时的 blob/formData 解析逻辑。
|
|
428
|
+
*/
|
|
429
|
+
private toFetchResponse(buffer: Buffer, response: Dispatcher.ResponseData): Response {
|
|
430
|
+
const contentType = response.headers['content-type'];
|
|
431
|
+
return new Response(buffer, {
|
|
432
|
+
headers: typeof contentType === 'string' ? { 'content-type': contentType } : undefined
|
|
433
|
+
});
|
|
400
434
|
}
|
|
401
435
|
|
|
402
436
|
/**
|
|
403
437
|
* 读取响应体
|
|
404
438
|
* @param response Undici 响应对象
|
|
405
|
-
* @param
|
|
439
|
+
* @param options 请求选项
|
|
406
440
|
*/
|
|
407
|
-
private async readResponseBody<T>(response: Dispatcher.ResponseData,
|
|
441
|
+
private async readResponseBody<T>(response: Dispatcher.ResponseData, options: HttpRequestOptions): Promise<T> {
|
|
442
|
+
if (options.responseType === 'stream') {
|
|
443
|
+
// undici 返回 BodyReadable (继承自 Node.js Readable),流式响应由调用方自行消费,不受 maxResponseBodySize 限制
|
|
444
|
+
return response.body as T;
|
|
445
|
+
}
|
|
446
|
+
|
|
408
447
|
try {
|
|
409
|
-
|
|
448
|
+
const buffer = await this.readBodyWithLimit(response, options);
|
|
449
|
+
const decoder = new TextDecoder();
|
|
450
|
+
switch (options.responseType) {
|
|
410
451
|
case 'text': {
|
|
411
|
-
return
|
|
452
|
+
return decoder.decode(buffer) as T;
|
|
412
453
|
}
|
|
413
454
|
case 'arraybuffer': {
|
|
414
|
-
return
|
|
415
|
-
}
|
|
416
|
-
case 'stream': {
|
|
417
|
-
// undici 返回 BodyReadable (继承自 Node.js Readable)
|
|
418
|
-
return response.body as T;
|
|
455
|
+
return buffer as T;
|
|
419
456
|
}
|
|
420
457
|
case 'blob': {
|
|
421
|
-
return (await response.
|
|
458
|
+
return (await this.toFetchResponse(buffer, response).blob()) as T;
|
|
422
459
|
}
|
|
423
460
|
case 'formdata': {
|
|
424
|
-
return (await response.
|
|
425
|
-
}
|
|
426
|
-
case 'json': {
|
|
427
|
-
return await this.readJsonResponseBody<T>(response);
|
|
461
|
+
return (await this.toFetchResponse(buffer, response).formData()) as T;
|
|
428
462
|
}
|
|
463
|
+
case 'json':
|
|
429
464
|
default: {
|
|
430
|
-
|
|
465
|
+
// 按 axios 的默认兼容行为:空响应体或非 JSON 文本不应让成功状态码请求因为解析失败变成异常
|
|
466
|
+
const text = decoder.decode(buffer);
|
|
431
467
|
try {
|
|
432
468
|
return JSON.parse(text) as T;
|
|
433
469
|
} catch {
|
|
434
|
-
// 如果不是有效的 JSON,返回原文本
|
|
435
470
|
return text as T;
|
|
436
471
|
}
|
|
437
472
|
}
|
|
438
|
-
|
|
439
473
|
}
|
|
440
474
|
} catch (error) {
|
|
475
|
+
// 响应体超限错误需要原样抛出,保留错误码、文案与不可重试标记
|
|
476
|
+
if (error instanceof ResponseTooLargeError) {
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
441
479
|
throw new Error(`Failed to read response body: ${ error instanceof Error ? error.message : String(error) }`);
|
|
442
480
|
}
|
|
443
481
|
}
|
|
444
482
|
|
|
445
|
-
/**
|
|
446
|
-
* 按 axios 的默认兼容行为读取 JSON 响应。
|
|
447
|
-
* 空响应体或非 JSON 文本不应让成功状态码请求因为解析失败变成异常。
|
|
448
|
-
*/
|
|
449
|
-
private async readJsonResponseBody<T>(response: Dispatcher.ResponseData): Promise<T> {
|
|
450
|
-
const text = await response.body.text();
|
|
451
|
-
if (!text) {
|
|
452
|
-
return text as T;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
try {
|
|
456
|
-
return JSON.parse(text) as T;
|
|
457
|
-
} catch {
|
|
458
|
-
return text as T;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
483
|
/**
|
|
463
484
|
* 获取状态文本
|
|
464
485
|
* @param statusCode 状态码
|
package/src/client.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { EClientAdapter } from './const';
|
|
|
32
32
|
import { HttpError } from './types';
|
|
33
33
|
import {
|
|
34
34
|
DomainUtils,
|
|
35
|
+
RedirectUtils,
|
|
35
36
|
TraceContextUtils
|
|
36
37
|
} from './utils';
|
|
37
38
|
import {
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
pipeline,
|
|
40
41
|
Readable
|
|
41
42
|
} from 'node:stream';
|
|
43
|
+
import { finished } from 'node:stream/promises';
|
|
42
44
|
import { MetricsTransform } from './stream';
|
|
43
45
|
|
|
44
46
|
type ForwardProxyDecision = {
|
|
@@ -189,84 +191,120 @@ export class NsHttpClient implements IHttpClient {
|
|
|
189
191
|
const startTime = Date.now();
|
|
190
192
|
|
|
191
193
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
skipDnsValidationReason: forwardProxyDecision.shouldProxy ? 'forward_proxy' : undefined
|
|
200
|
-
}
|
|
201
|
-
);
|
|
194
|
+
return await this.safeRequest<T>(options, this.config.request.maxRedirects);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
// 记录错误日志
|
|
197
|
+
this.logError(options, error, Date.now() - startTime);
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
202
201
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
202
|
+
/**
|
|
203
|
+
*
|
|
204
|
+
* @param options 请求选项
|
|
205
|
+
* @param remainedRedirects 剩余可重定向次数
|
|
206
|
+
* @returns 响应结果
|
|
207
|
+
*/
|
|
208
|
+
private async safeRequest<T>(options: HttpRequestOptions, remainedRedirects: number): Promise<HttpResponse<T>> {
|
|
209
|
+
const startTime = Date.now();
|
|
210
|
+
const forwardProxyDecision = this.resolveForwardProxyDecision(options.url);
|
|
211
211
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
212
|
+
// 安全验证
|
|
213
|
+
const { safeUrl, result: ssrfResult } = await this.ssrfGuard.validateAndGetSafeUrl(
|
|
214
|
+
options.url,
|
|
215
|
+
{
|
|
216
|
+
skipDnsValidation: forwardProxyDecision.shouldProxy,
|
|
217
|
+
skipDnsValidationReason: forwardProxyDecision.shouldProxy ? 'forward_proxy' : undefined
|
|
217
218
|
}
|
|
219
|
+
);
|
|
218
220
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
this.logger.debug('URL validation passed', {
|
|
222
|
+
originalUrl: options.url,
|
|
223
|
+
safeUrl,
|
|
224
|
+
resolvedIps: ssrfResult.resolvedIps,
|
|
225
|
+
dnsValidationSkipped: ssrfResult.dnsValidationSkipped,
|
|
226
|
+
dnsValidationSkipReason: ssrfResult.dnsValidationSkipReason,
|
|
227
|
+
scenario: options.scenario
|
|
228
|
+
});
|
|
224
229
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
});
|
|
232
|
-
} else {
|
|
233
|
-
safeOptions.headers = {
|
|
234
|
-
...safeOptions.headers,
|
|
235
|
-
traceparent: options.traceparent
|
|
236
|
-
};
|
|
230
|
+
if (ssrfResult.dnsValidationSkipped && forwardProxyDecision.shouldProxy) {
|
|
231
|
+
this.logger.debug('Skipped target DNS validation because request uses forward proxy', {
|
|
232
|
+
hostname: forwardProxyDecision.hostname,
|
|
233
|
+
proxyUrl: forwardProxyDecision.proxyUrl
|
|
234
|
+
});
|
|
235
|
+
}
|
|
237
236
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
237
|
+
// 使用安全的 URL 发起请求
|
|
238
|
+
const safeOptions: HttpRequestOptions = {
|
|
239
|
+
...options,
|
|
240
|
+
url: safeUrl
|
|
241
|
+
};
|
|
244
242
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
243
|
+
// 处理 W3C Trace Context traceparent 头
|
|
244
|
+
if (options.traceparent) {
|
|
245
|
+
// 验证 traceparent 格式
|
|
246
|
+
if (!TraceContextUtils.isValidTraceParent(options.traceparent)) {
|
|
247
|
+
this.logger.warn('Invalid traceparent format, ignoring', {
|
|
248
|
+
traceparent: options.traceparent
|
|
249
|
+
});
|
|
250
|
+
} else {
|
|
251
|
+
safeOptions.headers = {
|
|
252
|
+
...safeOptions.headers,
|
|
253
|
+
traceparent: options.traceparent
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
this.logger.debug('Traceparent header added', {
|
|
257
|
+
traceparent: options.traceparent,
|
|
258
|
+
sampled: TraceContextUtils.isSampled(options.traceparent)
|
|
259
|
+
});
|
|
248
260
|
}
|
|
261
|
+
}
|
|
249
262
|
|
|
250
|
-
|
|
251
|
-
|
|
263
|
+
// 应用正向代理配置
|
|
264
|
+
if (this.forwardProxy) {
|
|
265
|
+
this.applyForwardProxy(safeOptions, forwardProxyDecision);
|
|
266
|
+
}
|
|
252
267
|
|
|
253
|
-
|
|
254
|
-
|
|
268
|
+
// 发起请求
|
|
269
|
+
const response = await this.adapter.request<T>(safeOptions);
|
|
255
270
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
271
|
+
// 需要重定向时,继续跟跳
|
|
272
|
+
if (RedirectUtils.isRedirectStatus(response.status)) {
|
|
273
|
+
try {
|
|
274
|
+
if (remainedRedirects === undefined || remainedRedirects <= 0) {
|
|
275
|
+
throw new Error('max redirects');
|
|
276
|
+
}
|
|
277
|
+
const redirectOptions = RedirectUtils.buildRedirectOptions(
|
|
278
|
+
safeOptions,
|
|
279
|
+
response,
|
|
280
|
+
this.adapter.name,
|
|
281
|
+
this.config.request.timeout
|
|
282
|
+
);
|
|
283
|
+
if (redirectOptions) {
|
|
284
|
+
RedirectUtils.consumeRedirectTimeout(redirectOptions, startTime);
|
|
285
|
+
// 下一跳前,把中间响应的流先丢掉,以免占用连接
|
|
286
|
+
await this.discardResponseStream(response);
|
|
287
|
+
const nextRemainedRedirects = remainedRedirects - 1;
|
|
288
|
+
return await this.safeRequest<T>(redirectOptions, nextRemainedRedirects);
|
|
289
|
+
}
|
|
290
|
+
} catch (error) {
|
|
291
|
+
await this.discardResponseStream(response);
|
|
292
|
+
throw error;
|
|
259
293
|
}
|
|
294
|
+
}
|
|
260
295
|
|
|
261
|
-
|
|
262
|
-
|
|
296
|
+
// 首次响应时间(TTFB)
|
|
297
|
+
const ttfb = Date.now() - startTime;
|
|
263
298
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.logError(options, error, Date.now() - startTime);
|
|
268
|
-
throw error;
|
|
299
|
+
// 如果是流式响应,包装流对象以追踪完整生命周期
|
|
300
|
+
if (options.responseType === 'stream' && this.isStreamResponse(response.data)) {
|
|
301
|
+
return this.wrapStreamResponse(safeOptions, response, startTime, ttfb);
|
|
269
302
|
}
|
|
303
|
+
|
|
304
|
+
// 记录非流式响应日志
|
|
305
|
+
this.logResponse(safeOptions, response, ttfb);
|
|
306
|
+
|
|
307
|
+
return response;
|
|
270
308
|
}
|
|
271
309
|
|
|
272
310
|
/**
|
|
@@ -354,6 +392,19 @@ export class NsHttpClient implements IHttpClient {
|
|
|
354
392
|
});
|
|
355
393
|
}
|
|
356
394
|
|
|
395
|
+
/**
|
|
396
|
+
* 销毁不再交给调用方的响应流
|
|
397
|
+
*/
|
|
398
|
+
private async discardResponseStream(response: HttpResponse): Promise<void> {
|
|
399
|
+
const data = response.data;
|
|
400
|
+
if (!(data instanceof Readable) || data.destroyed) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const done = finished(data);
|
|
404
|
+
data.destroy();
|
|
405
|
+
await done.catch(() => undefined);
|
|
406
|
+
}
|
|
407
|
+
|
|
357
408
|
/**
|
|
358
409
|
* 判断响应数据是否为流对象(Node.js Readable)
|
|
359
410
|
*/
|
package/src/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { DEFAULT_DNS_CACHE_TTL, DEFAULT_DNS_CACHE_MAX_SIZE } from './dns.const';
|
|
|
11
11
|
* DNS 缓存最大 TTL(毫秒)- 5 分钟
|
|
12
12
|
* 避免过长的缓存时间导致内存积压
|
|
13
13
|
*/
|
|
14
|
-
export const MAX_DNS_CACHE_TTL = 5 * 60 * 1000; // 300000ms (5 分钟)
|
|
14
|
+
export const MAX_DNS_CACHE_TTL = 5 * 60 * 1000; // 300000ms (5 分钟)
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* 默认安全配置
|
|
@@ -41,7 +41,6 @@ export const DEFAULT_LOGGING_CONFIG: LoggingConfig = {
|
|
|
41
41
|
* 默认请求配置
|
|
42
42
|
*/
|
|
43
43
|
export const DEFAULT_REQUEST_CONFIG: RequestConfig = {
|
|
44
|
-
timeout: 30000, // 30 秒
|
|
45
44
|
retries: 0,
|
|
46
45
|
retryDelay: 1000, // 1 秒
|
|
47
46
|
maxRedirects: 5,
|
|
@@ -49,6 +48,13 @@ export const DEFAULT_REQUEST_CONFIG: RequestConfig = {
|
|
|
49
48
|
maxResponseBodySize: 50 * 1024 * 1024 // 50MB
|
|
50
49
|
};
|
|
51
50
|
|
|
51
|
+
/**
|
|
52
|
+
* 响应体大小预检的线上长度容差比例。
|
|
53
|
+
* axios 的 onDownloadProgress.total 是压缩后的 Content-Length,而 maxContentLength 按解压后计数;
|
|
54
|
+
* 不可压缩数据经 gzip 等压缩后可能略大于解压大小,预检仅在超过上限该比例时提前中止。
|
|
55
|
+
*/
|
|
56
|
+
export const MAX_RESPONSE_SIZE_TOLERANCE_RATIO = 0.01;
|
|
57
|
+
|
|
52
58
|
/**
|
|
53
59
|
* 默认 HTTP 客户端配置
|
|
54
60
|
*/
|
package/src/types/config.ts
CHANGED
|
@@ -68,8 +68,8 @@ export interface LoggingConfig {
|
|
|
68
68
|
* 请求配置
|
|
69
69
|
*/
|
|
70
70
|
export interface RequestConfig {
|
|
71
|
-
/**
|
|
72
|
-
timeout
|
|
71
|
+
/** 超时时间(毫秒);不配置表示不限制请求超时 */
|
|
72
|
+
timeout?: number;
|
|
73
73
|
/** 重试次数 */
|
|
74
74
|
retries: number;
|
|
75
75
|
/** 重试延迟(毫秒) */
|
|
@@ -107,4 +107,3 @@ export interface PartialHttpClientConfig {
|
|
|
107
107
|
logging?: Partial<LoggingConfig>;
|
|
108
108
|
request?: Partial<RequestConfig>;
|
|
109
109
|
}
|
|
110
|
-
|