nstarter-http-request 0.1.12 → 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.
Files changed (60) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/cjs/adapter/axios.adapter.js +93 -1
  3. package/dist/cjs/adapter/axios.adapter.js.map +1 -1
  4. package/dist/cjs/adapter/base.js +20 -1
  5. package/dist/cjs/adapter/base.js.map +1 -1
  6. package/dist/cjs/adapter/undici.adapter.js +82 -70
  7. package/dist/cjs/adapter/undici.adapter.js.map +1 -1
  8. package/dist/cjs/client.js +99 -56
  9. package/dist/cjs/client.js.map +1 -1
  10. package/dist/cjs/const/config.const.js +7 -1
  11. package/dist/cjs/const/config.const.js.map +1 -1
  12. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  13. package/dist/cjs/types/errors.js +30 -2
  14. package/dist/cjs/types/errors.js.map +1 -1
  15. package/dist/cjs/utils/index.js +2 -1
  16. package/dist/cjs/utils/index.js.map +1 -1
  17. package/dist/cjs/utils/redirect.js +138 -0
  18. package/dist/cjs/utils/redirect.js.map +1 -0
  19. package/dist/esm/adapter/axios.adapter.js +95 -3
  20. package/dist/esm/adapter/axios.adapter.js.map +1 -1
  21. package/dist/esm/adapter/base.js +20 -1
  22. package/dist/esm/adapter/base.js.map +1 -1
  23. package/dist/esm/adapter/undici.adapter.js +83 -71
  24. package/dist/esm/adapter/undici.adapter.js.map +1 -1
  25. package/dist/esm/client.js +100 -57
  26. package/dist/esm/client.js.map +1 -1
  27. package/dist/esm/const/config.const.js +6 -0
  28. package/dist/esm/const/config.const.js.map +1 -1
  29. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  30. package/dist/esm/types/errors.js +28 -1
  31. package/dist/esm/types/errors.js.map +1 -1
  32. package/dist/esm/utils/index.js +1 -0
  33. package/dist/esm/utils/index.js.map +1 -1
  34. package/dist/esm/utils/redirect.js +133 -0
  35. package/dist/esm/utils/redirect.js.map +1 -0
  36. package/dist/types/adapter/axios.adapter.d.ts +22 -0
  37. package/dist/types/adapter/axios.adapter.d.ts.map +1 -1
  38. package/dist/types/adapter/base.d.ts +6 -0
  39. package/dist/types/adapter/base.d.ts.map +1 -1
  40. package/dist/types/adapter/undici.adapter.d.ts +20 -11
  41. package/dist/types/adapter/undici.adapter.d.ts.map +1 -1
  42. package/dist/types/client.d.ts +11 -0
  43. package/dist/types/client.d.ts.map +1 -1
  44. package/dist/types/const/config.const.d.ts +6 -0
  45. package/dist/types/const/config.const.d.ts.map +1 -1
  46. package/dist/types/types/errors.d.ts +26 -1
  47. package/dist/types/types/errors.d.ts.map +1 -1
  48. package/dist/types/utils/index.d.ts +1 -0
  49. package/dist/types/utils/index.d.ts.map +1 -1
  50. package/dist/types/utils/redirect.d.ts +26 -0
  51. package/dist/types/utils/redirect.d.ts.map +1 -0
  52. package/package.json +1 -1
  53. package/src/adapter/axios.adapter.ts +124 -3
  54. package/src/adapter/base.ts +22 -1
  55. package/src/adapter/undici.adapter.ts +95 -83
  56. package/src/client.ts +114 -63
  57. package/src/const/config.const.ts +7 -0
  58. package/src/types/errors.ts +39 -1
  59. package/src/utils/index.ts +1 -0
  60. package/src/utils/redirect.ts +163 -0
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
- const forwardProxyDecision = this.resolveForwardProxyDecision(options.url);
193
-
194
- // 安全验证
195
- const { safeUrl, result: ssrfResult } = await this.ssrfGuard.validateAndGetSafeUrl(
196
- options.url,
197
- {
198
- skipDnsValidation: forwardProxyDecision.shouldProxy,
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
- this.logger.debug('URL validation passed', {
204
- originalUrl: options.url,
205
- safeUrl,
206
- resolvedIps: ssrfResult.resolvedIps,
207
- dnsValidationSkipped: ssrfResult.dnsValidationSkipped,
208
- dnsValidationSkipReason: ssrfResult.dnsValidationSkipReason,
209
- scenario: options.scenario
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
- if (ssrfResult.dnsValidationSkipped && forwardProxyDecision.shouldProxy) {
213
- this.logger.debug('Skipped target DNS validation because request uses forward proxy', {
214
- hostname: forwardProxyDecision.hostname,
215
- proxyUrl: forwardProxyDecision.proxyUrl
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
- // 使用安全的 URL 发起请求
220
- const safeOptions: HttpRequestOptions = {
221
- ...options,
222
- url: safeUrl
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
- // 处理 W3C Trace Context traceparent 头
226
- if (options.traceparent) {
227
- // 验证 traceparent 格式
228
- if (!TraceContextUtils.isValidTraceParent(options.traceparent)) {
229
- this.logger.warn('Invalid traceparent format, ignoring', {
230
- traceparent: options.traceparent
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
- this.logger.debug('Traceparent header added', {
239
- traceparent: options.traceparent,
240
- sampled: TraceContextUtils.isSampled(options.traceparent)
241
- });
242
- }
243
- }
237
+ // 使用安全的 URL 发起请求
238
+ const safeOptions: HttpRequestOptions = {
239
+ ...options,
240
+ url: safeUrl
241
+ };
244
242
 
245
- // 应用正向代理配置
246
- if (this.forwardProxy) {
247
- this.applyForwardProxy(safeOptions, forwardProxyDecision);
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
- const response = await this.adapter.request<T>(safeOptions);
263
+ // 应用正向代理配置
264
+ if (this.forwardProxy) {
265
+ this.applyForwardProxy(safeOptions, forwardProxyDecision);
266
+ }
252
267
 
253
- // 首次响应时间(TTFB)
254
- const ttfb = Date.now() - startTime;
268
+ // 发起请求
269
+ const response = await this.adapter.request<T>(safeOptions);
255
270
 
256
- // 如果是流式响应,包装流对象以追踪完整生命周期
257
- if (options.responseType === 'stream' && this.isStreamResponse(response.data)) {
258
- return this.wrapStreamResponse(safeOptions, response, startTime, ttfb);
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
- this.logResponse(safeOptions, response, ttfb);
296
+ // 首次响应时间(TTFB)
297
+ const ttfb = Date.now() - startTime;
263
298
 
264
- return response;
265
- } catch (error) {
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
  */
@@ -48,6 +48,13 @@ export const DEFAULT_REQUEST_CONFIG: RequestConfig = {
48
48
  maxResponseBodySize: 50 * 1024 * 1024 // 50MB
49
49
  };
50
50
 
51
+ /**
52
+ * 响应体大小预检的线上长度容差比例。
53
+ * axios 的 onDownloadProgress.total 是压缩后的 Content-Length,而 maxContentLength 按解压后计数;
54
+ * 不可压缩数据经 gzip 等压缩后可能略大于解压大小,预检仅在超过上限该比例时提前中止。
55
+ */
56
+ export const MAX_RESPONSE_SIZE_TOLERANCE_RATIO = 0.01;
57
+
51
58
  /**
52
59
  * 默认 HTTP 客户端配置
53
60
  */
@@ -8,12 +8,21 @@ import type { HttpRequestOptions, HttpResponse } from './request_response';
8
8
  * HTTP 错误
9
9
  */
10
10
  export class HttpError extends Error {
11
+ /**
12
+ * @param message 错误信息
13
+ * @param code 错误码
14
+ * @param status HTTP 状态码
15
+ * @param response 响应(不应携带不再需要的大体积响应体)
16
+ * @param request 请求选项
17
+ * @param retriable 是否允许重试,默认 true;重试无法改变结果的确定性失败应传 false
18
+ */
11
19
  constructor(
12
20
  message: string,
13
21
  public readonly code: string,
14
22
  public readonly status?: number,
15
23
  public readonly response?: HttpResponse,
16
- public readonly request?: HttpRequestOptions
24
+ public readonly request?: HttpRequestOptions,
25
+ public readonly retriable = true
17
26
  ) {
18
27
  super(message);
19
28
  this.name = 'HttpError';
@@ -21,6 +30,35 @@ export class HttpError extends Error {
21
30
  }
22
31
  }
23
32
 
33
+ /**
34
+ * 响应体超过 maxResponseBodySize 限制的错误。
35
+ *
36
+ * - 错误码与文案对齐 axios 的 maxContentLength(ERR_BAD_RESPONSE)。
37
+ * - 重试不会改变响应体大小,因此标记为不可重试,避免每次重试都重复下载。
38
+ * - 不携带 response:仅保留状态码,防止超限数据随错误对象存活(与 axios 抛出该错误时一致)。
39
+ */
40
+ export class ResponseTooLargeError extends HttpError {
41
+ /**
42
+ * @param maxResponseBodySize 响应体大小上限
43
+ * @param request 请求选项
44
+ * @param status 响应状态码(未知时不传)
45
+ */
46
+ constructor(
47
+ public readonly maxResponseBodySize: number,
48
+ request: HttpRequestOptions,
49
+ status?: number
50
+ ) {
51
+ super(
52
+ `maxContentLength size of ${ maxResponseBodySize } exceeded`,
53
+ 'ERR_BAD_RESPONSE',
54
+ status,
55
+ undefined,
56
+ request,
57
+ false
58
+ );
59
+ }
60
+ }
61
+
24
62
  /**
25
63
  * 安全验证错误
26
64
  */
@@ -1,6 +1,7 @@
1
1
  export * as IpRangeUtils from './ip.range';
2
2
  export * as CommonUtils from './common';
3
3
  export * as DomainUtils from './domain';
4
+ export * as RedirectUtils from './redirect';
4
5
  export * as TraceContextUtils from './trace.context';
5
6
 
6
7
  // 导出 TraceContext 类供外部直接使用
@@ -0,0 +1,163 @@
1
+ import type {
2
+ HttpRequestOptions,
3
+ HttpResponse
4
+ } from '../types';
5
+ import { HttpError } from '../types';
6
+ import { Readable } from 'node:stream';
7
+ import { EClientAdapter } from '../const';
8
+
9
+ /**
10
+ * 交给调用方跟跳的状态码,与 undici 的 redirectableStatusCodes 一致。
11
+ * 304 表示未修改,305 表示改走代理,306 已废弃,这三类不跟。
12
+ */
13
+ const REDIRECT_STATUS: ReadonlySet<number> = new Set([
14
+ 300,
15
+ 301,
16
+ 302,
17
+ 303,
18
+ 307,
19
+ 308
20
+ ]);
21
+
22
+ /**
23
+ * 判断状态码是否需要跟跳。
24
+ * @param status HTTP 状态码
25
+ */
26
+ export function isRedirectStatus(status: number): boolean {
27
+ return REDIRECT_STATUS.has(status);
28
+ }
29
+
30
+ /**
31
+ * 根据响应里的 Location 构造下一跳请求。
32
+ * 没有 Location 时返回 undefined,调用方把当前 3xx 原样返回。
33
+ * axios:POST 的 301/302,以及非 GET/HEAD 的 303,改成 GET 并去掉 body 和 content-*。
34
+ * undici:只有非 HEAD 的 303 改成 GET 并去掉 body;303 仍去掉 content-*。
35
+ * 两边都去掉 host;跨域去掉 authorization、cookie、proxy-authorization。
36
+ * @param options 当前这一跳的请求
37
+ * @param response 当前这一跳的响应
38
+ * @param adapter 当前适配器
39
+ * @param configuredTimeout 客户端配置中的超时。请求未单独设置时使用。
40
+ */
41
+ export function buildRedirectOptions(
42
+ options: HttpRequestOptions,
43
+ response: HttpResponse,
44
+ adapter: EClientAdapter,
45
+ configuredTimeout?: number
46
+ ): HttpRequestOptions | undefined {
47
+ const location = response.headers?.location;
48
+ if (!location) {
49
+ return;
50
+ }
51
+ const status = response.status;
52
+ const currentUrl = new URL(options.url);
53
+ const nextUrl = new URL(location, currentUrl);
54
+ const headers = { ...options.headers };
55
+ const crossOrigin = currentUrl.origin !== nextUrl.origin;
56
+ const rewriteToGet = shouldRewriteToGet(adapter, status, options.method);
57
+ const removeContentHeaders = adapter === EClientAdapter.axios
58
+ ? rewriteToGet
59
+ : status === 303;
60
+
61
+ // 清理请求头
62
+ for (const key of Object.keys(headers)) {
63
+ const name = key.toLowerCase();
64
+ if (
65
+ name === 'host'
66
+ || (removeContentHeaders && name.startsWith('content-'))
67
+ || (crossOrigin && (name === 'authorization' || name === 'cookie' || name === 'proxy-authorization'))
68
+ ) {
69
+ delete headers[key];
70
+ }
71
+ }
72
+ const nextOptions: HttpRequestOptions = {
73
+ ...options,
74
+ url: nextUrl.toString(),
75
+ params: undefined,
76
+ headers,
77
+ timeout: options.timeout ?? configuredTimeout,
78
+ meta: options.meta ? { ...options.meta } : undefined
79
+ };
80
+ // 清理 meta 中的正向代理信息
81
+ if (nextOptions.meta) {
82
+ delete nextOptions.meta.__forwardProxy;
83
+ }
84
+ if (rewriteToGet) {
85
+ nextOptions.method = 'GET';
86
+ nextOptions.body = undefined;
87
+ } else if (!isReplayable(options.body)) {
88
+ throw new Error('Request body is not replayable');
89
+ }
90
+ return nextOptions;
91
+ }
92
+
93
+ /**
94
+ * axios 的 301/302 只改 POST。undici 的 301/302 保持原方法。
95
+ * 303 时两边都把非 HEAD 改成 GET;axios 额外保留已经是 GET 的请求。
96
+ */
97
+ function shouldRewriteToGet(adapter: EClientAdapter, status: number, method: HttpRequestOptions['method']): boolean {
98
+ if (adapter === EClientAdapter.axios) {
99
+ return ((status === 301 || status === 302) && method === 'POST')
100
+ || (status === 303 && method !== 'GET' && method !== 'HEAD');
101
+ }
102
+ return status === 303 && method !== 'HEAD';
103
+ }
104
+
105
+ /**
106
+ * 字符串、Buffer、普通对象每次都能重新序列化。
107
+ * 流和异步可迭代对象读过一次就不能再发。
108
+ */
109
+ function isReplayable(body: unknown): boolean {
110
+ if (
111
+ body === null
112
+ || body === undefined
113
+ || typeof body === 'string'
114
+ || typeof body === 'number'
115
+ || typeof body === 'boolean'
116
+ || Buffer.isBuffer(body)
117
+ || body instanceof ArrayBuffer
118
+ || ArrayBuffer.isView(body)
119
+ || body instanceof URLSearchParams
120
+ || (typeof Blob !== 'undefined' && body instanceof Blob)
121
+ || (typeof FormData !== 'undefined' && body instanceof FormData)
122
+ ) {
123
+ return true;
124
+ }
125
+ if (body instanceof Readable) {
126
+ return false;
127
+ }
128
+ if (typeof body !== 'object') {
129
+ return true;
130
+ }
131
+ const candidate = body as {
132
+ getReader?: unknown,
133
+ [Symbol.asyncIterator]?: unknown
134
+ };
135
+ return typeof candidate.getReader !== 'function'
136
+ && typeof candidate[Symbol.asyncIterator] !== 'function';
137
+ }
138
+
139
+ /**
140
+ * 跟跳共用同一次超时。把本跳已经花掉的时间从 redirectOptions.timeout 扣掉。
141
+ * @param redirectOptions 下一跳请求
142
+ * @param startTime 本跳开始时间
143
+ */
144
+ export function consumeRedirectTimeout(
145
+ redirectOptions: HttpRequestOptions,
146
+ startTime: number
147
+ ): void {
148
+ const timeout = redirectOptions.timeout;
149
+ if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) {
150
+ return;
151
+ }
152
+ const remaining = timeout - (Date.now() - startTime);
153
+ if (remaining <= 0) {
154
+ throw new HttpError(
155
+ `timeout of ${ timeout }ms exceeded`,
156
+ 'ECONNABORTED',
157
+ undefined,
158
+ undefined,
159
+ redirectOptions
160
+ );
161
+ }
162
+ redirectOptions.timeout = remaining;
163
+ }