@vobs/http 1.0.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/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +21 -0
- package/src/debug.ts +64 -0
- package/src/index.test.ts +499 -0
- package/src/index.ts +847 -0
- package/src/stream.ts +128 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
import { createInjectionKey, type VobsPlugin } from '@vobs/vobs'
|
|
2
|
+
import axios from 'axios'
|
|
3
|
+
export { createSSE, createWebSocket } from './stream'
|
|
4
|
+
import { emitHTTPDebug } from './debug'
|
|
5
|
+
import { getRuntimeDebugContext } from '@vobs/runtime'
|
|
6
|
+
|
|
7
|
+
export { emitHTTPDebug, getHTTPDebugHooks, setHTTPDebugHooks, subscribeHTTPDebug } from './debug'
|
|
8
|
+
export type { HTTPDebugCacheStatus, HTTPDebugContext, HTTPDebugHooks, HTTPDebugRequest, HTTPDebugStatus } from './debug'
|
|
9
|
+
export type {
|
|
10
|
+
SSEClient,
|
|
11
|
+
SSEConstructor,
|
|
12
|
+
SSEOptions,
|
|
13
|
+
WebSocketClient,
|
|
14
|
+
WebSocketConstructor,
|
|
15
|
+
WebSocketEventListener,
|
|
16
|
+
WebSocketEventName,
|
|
17
|
+
WebSocketOptions,
|
|
18
|
+
WebSocketState
|
|
19
|
+
} from './stream'
|
|
20
|
+
|
|
21
|
+
export type HTTPMethod = 'DELETE' | 'GET' | 'HEAD' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT'
|
|
22
|
+
export type HTTPResponseType = 'arrayBuffer' | 'blob' | 'json' | 'response' | 'text'
|
|
23
|
+
export type HTTPHeaders = Record<string, string>
|
|
24
|
+
export type RetryDelay = number | ((attempt: number, error: Error) => number)
|
|
25
|
+
export interface HTTPProgress {
|
|
26
|
+
readonly loaded: number
|
|
27
|
+
readonly total: number | undefined
|
|
28
|
+
readonly percent: number | undefined
|
|
29
|
+
}
|
|
30
|
+
export type HTTPProgressHandler = (progress: HTTPProgress) => void
|
|
31
|
+
const PROGRESS_HANDLED = Symbol('vobs.http.progress-handled')
|
|
32
|
+
let nextHTTPDebugId = 1
|
|
33
|
+
|
|
34
|
+
export interface RequestOptions {
|
|
35
|
+
url: string
|
|
36
|
+
method?: HTTPMethod
|
|
37
|
+
baseURL?: string
|
|
38
|
+
headers?: HeadersInit
|
|
39
|
+
params?: Record<string, unknown> | URLSearchParams
|
|
40
|
+
body?: unknown
|
|
41
|
+
signal?: AbortSignal
|
|
42
|
+
/** @deprecated Use signal. */
|
|
43
|
+
state?: AbortSignal
|
|
44
|
+
timeout?: number
|
|
45
|
+
retry?: number
|
|
46
|
+
retryDelay?: RetryDelay
|
|
47
|
+
shouldRetry?: (error: Error, attempt: number) => boolean | PromiseLike<boolean>
|
|
48
|
+
cache?: RequestCache
|
|
49
|
+
credentials?: RequestCredentials
|
|
50
|
+
mode?: RequestMode
|
|
51
|
+
responseType?: HTTPResponseType
|
|
52
|
+
onUploadProgress?: HTTPProgressHandler
|
|
53
|
+
onDownloadProgress?: HTTPProgressHandler
|
|
54
|
+
/** Share an in-flight request with the same dedupe key. */
|
|
55
|
+
dedupe?: boolean
|
|
56
|
+
dedupeKey?: string
|
|
57
|
+
/** Optional context copied into DevTools request traces. */
|
|
58
|
+
debugContext?: import('./debug').HTTPDebugContext
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RequestConfig extends Omit<RequestOptions, 'headers' | 'method'> {
|
|
62
|
+
readonly method: HTTPMethod
|
|
63
|
+
headers: HTTPHeaders
|
|
64
|
+
readonly url: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface HTTPResponse<T = unknown> {
|
|
68
|
+
readonly data: T
|
|
69
|
+
readonly status: number
|
|
70
|
+
readonly statusText: string
|
|
71
|
+
readonly headers: Headers
|
|
72
|
+
readonly config: RequestConfig
|
|
73
|
+
readonly raw: Response | null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Adapt an HTTP request to the fetcher contract used by Resource. */
|
|
77
|
+
export type HTTPResourceRequest<T> = (
|
|
78
|
+
signal: AbortSignal
|
|
79
|
+
) => HTTPResponse<T> | PromiseLike<HTTPResponse<T>>
|
|
80
|
+
|
|
81
|
+
export function toResourceFetcher<T>(request: HTTPResourceRequest<T>): (signal: AbortSignal) => Promise<T> {
|
|
82
|
+
return signal => Promise.resolve()
|
|
83
|
+
.then(() => request(signal))
|
|
84
|
+
.then(response => response.data)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type HTTPAdapter = (
|
|
88
|
+
config: RequestConfig
|
|
89
|
+
) => Response | HTTPResponse<unknown> | PromiseLike<Response | HTTPResponse<unknown>>
|
|
90
|
+
|
|
91
|
+
export interface AxiosResponseLike<T = unknown> {
|
|
92
|
+
readonly data: T
|
|
93
|
+
readonly status: number
|
|
94
|
+
readonly statusText?: string
|
|
95
|
+
readonly headers?: HeadersInit
|
|
96
|
+
readonly raw?: Response | null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type AxiosRequestConfigLike = RequestConfig & { readonly data?: unknown }
|
|
100
|
+
export type AxiosRequest = <T = unknown>(config: AxiosRequestConfigLike) => PromiseLike<AxiosResponseLike<T>>
|
|
101
|
+
|
|
102
|
+
/** Bridges an Axios request function while preserving HTTPResponse<T>.data typing. */
|
|
103
|
+
export function createAxiosAdapter(request: AxiosRequest = builtInAxiosRequest): HTTPAdapter {
|
|
104
|
+
return async config => {
|
|
105
|
+
// normalizeRequest has already serialized params into config.url. Omit
|
|
106
|
+
// params before invoking a custom Axios request so spreading this config
|
|
107
|
+
// into axios.request cannot append the query string a second time.
|
|
108
|
+
const { params: _params, ...requestConfig } = config
|
|
109
|
+
const response = await request<unknown>({ ...requestConfig, data: config.body })
|
|
110
|
+
return {
|
|
111
|
+
data: response.data,
|
|
112
|
+
status: response.status,
|
|
113
|
+
statusText: response.statusText ?? '',
|
|
114
|
+
headers: new Headers(response.headers),
|
|
115
|
+
config,
|
|
116
|
+
raw: response.raw ?? null
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Creates an adapter for deterministic local handlers and test transports.
|
|
123
|
+
* Returning a Response or HTTPResponse keeps full control over status and
|
|
124
|
+
* headers; any other value becomes a successful 200 payload.
|
|
125
|
+
*/
|
|
126
|
+
export function createMockAdapter(
|
|
127
|
+
handler: (config: RequestConfig) => unknown | PromiseLike<unknown>
|
|
128
|
+
): HTTPAdapter {
|
|
129
|
+
return async config => {
|
|
130
|
+
const result = await handler(config)
|
|
131
|
+
if (isResponse(result) || isHTTPResponse(result)) return result
|
|
132
|
+
return {
|
|
133
|
+
data: result,
|
|
134
|
+
status: 200,
|
|
135
|
+
statusText: 'OK',
|
|
136
|
+
headers: new Headers(),
|
|
137
|
+
config,
|
|
138
|
+
raw: null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** XMLHttpRequest adapter for upload and download progress events. */
|
|
144
|
+
export function createXHRAdapter(): HTTPAdapter {
|
|
145
|
+
return config => new Promise<Response>((resolve, reject) => {
|
|
146
|
+
if (typeof XMLHttpRequest === 'undefined') {
|
|
147
|
+
reject(new Error('HTTP: 当前环境没有可用的 XMLHttpRequest'))
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
const xhr = new XMLHttpRequest()
|
|
151
|
+
xhr.open(config.method, config.url, true)
|
|
152
|
+
if (config.responseType === 'arrayBuffer') xhr.responseType = 'arraybuffer'
|
|
153
|
+
else if (config.responseType === 'blob') xhr.responseType = 'blob'
|
|
154
|
+
const signal = config.signal ?? config.state
|
|
155
|
+
const abort = (): void => xhr.abort()
|
|
156
|
+
if (signal?.aborted) {
|
|
157
|
+
reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }))
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
const cleanup = (): void => signal?.removeEventListener('abort', abort)
|
|
161
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
162
|
+
xhr.upload.onprogress = event => config.onUploadProgress?.(toProgress(event.loaded, event.lengthComputable ? event.total : undefined))
|
|
163
|
+
xhr.onprogress = event => config.onDownloadProgress?.(toProgress(event.loaded, event.lengthComputable ? event.total : undefined))
|
|
164
|
+
xhr.onload = () => {
|
|
165
|
+
cleanup()
|
|
166
|
+
const headers = new Headers()
|
|
167
|
+
xhr.getAllResponseHeaders().trim().split(/[\r\n]+/).forEach(line => {
|
|
168
|
+
const separator = line.indexOf(':')
|
|
169
|
+
if (separator > 0) headers.set(line.slice(0, separator).trim(), line.slice(separator + 1).trim())
|
|
170
|
+
})
|
|
171
|
+
const body = config.responseType === 'arrayBuffer' || config.responseType === 'blob'
|
|
172
|
+
? xhr.response
|
|
173
|
+
: xhr.responseText
|
|
174
|
+
const response = new Response(body, {
|
|
175
|
+
status: xhr.status,
|
|
176
|
+
statusText: xhr.statusText,
|
|
177
|
+
headers
|
|
178
|
+
})
|
|
179
|
+
Object.defineProperty(response, PROGRESS_HANDLED, { value: true })
|
|
180
|
+
resolve(response)
|
|
181
|
+
}
|
|
182
|
+
xhr.onerror = () => { cleanup(); reject(new Error('HTTP: XMLHttpRequest 网络错误')) }
|
|
183
|
+
xhr.onabort = () => {
|
|
184
|
+
cleanup()
|
|
185
|
+
reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }))
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const headers = new Headers(config.headers)
|
|
189
|
+
const body = encodeBody(config.body, headers, config.method)
|
|
190
|
+
headers.forEach((value, key) => xhr.setRequestHeader(key, value))
|
|
191
|
+
xhr.send((body ?? null) as Document | XMLHttpRequestBodyInit | null)
|
|
192
|
+
} catch (error) {
|
|
193
|
+
cleanup()
|
|
194
|
+
reject(error)
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Explicit fetch transport for applications that do not want the built-in Axios transport. */
|
|
200
|
+
export function createFetchAdapter(): HTTPAdapter {
|
|
201
|
+
return fetchAdapter
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface HTTPClientOptions {
|
|
205
|
+
baseURL?: string
|
|
206
|
+
headers?: HeadersInit
|
|
207
|
+
timeout?: number
|
|
208
|
+
adapter?: HTTPAdapter
|
|
209
|
+
retry?: number
|
|
210
|
+
retryDelay?: RetryDelay
|
|
211
|
+
shouldRetry?: (error: Error, attempt: number) => boolean | PromiseLike<boolean>
|
|
212
|
+
concurrency?: number
|
|
213
|
+
dedupe?: boolean
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface InterceptorManager<T> {
|
|
217
|
+
use<TResult = T>(
|
|
218
|
+
onFulfilled?: (value: T) => TResult | PromiseLike<TResult>,
|
|
219
|
+
onRejected?: (error: unknown) => TResult | PromiseLike<TResult>
|
|
220
|
+
): number
|
|
221
|
+
eject(id: number): void
|
|
222
|
+
clear(): void
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface HTTPInterceptors {
|
|
226
|
+
readonly request: InterceptorManager<RequestConfig>
|
|
227
|
+
readonly response: InterceptorManager<HTTPResponse<unknown>>
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface HTTPClient {
|
|
231
|
+
readonly interceptors: HTTPInterceptors
|
|
232
|
+
request<T = unknown>(options: RequestOptions): Promise<HTTPResponse<T>>
|
|
233
|
+
get<T = unknown>(url: string, options?: Omit<RequestOptions, 'url' | 'method' | 'body'>): Promise<HTTPResponse<T>>
|
|
234
|
+
delete<T = unknown>(url: string, options?: Omit<RequestOptions, 'url' | 'method' | 'body'>): Promise<HTTPResponse<T>>
|
|
235
|
+
head<T = unknown>(url: string, options?: Omit<RequestOptions, 'url' | 'method' | 'body'>): Promise<HTTPResponse<T>>
|
|
236
|
+
post<T = unknown>(url: string, body?: unknown, options?: Omit<RequestOptions, 'url' | 'method' | 'body'>): Promise<HTTPResponse<T>>
|
|
237
|
+
put<T = unknown>(url: string, body?: unknown, options?: Omit<RequestOptions, 'url' | 'method' | 'body'>): Promise<HTTPResponse<T>>
|
|
238
|
+
patch<T = unknown>(url: string, body?: unknown, options?: Omit<RequestOptions, 'url' | 'method' | 'body'>): Promise<HTTPResponse<T>>
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface ConcurrencyLimiter {
|
|
242
|
+
<T>(task: () => T | PromiseLike<T>): Promise<T>
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export class HTTPError extends Error {
|
|
246
|
+
readonly status: number
|
|
247
|
+
readonly statusText: string
|
|
248
|
+
readonly data: unknown
|
|
249
|
+
readonly config: RequestConfig
|
|
250
|
+
readonly response: HTTPResponse<unknown>
|
|
251
|
+
|
|
252
|
+
constructor(response: HTTPResponse<unknown>) {
|
|
253
|
+
super(`HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}`)
|
|
254
|
+
this.name = 'HTTPError'
|
|
255
|
+
this.status = response.status
|
|
256
|
+
this.statusText = response.statusText
|
|
257
|
+
this.data = response.data
|
|
258
|
+
this.config = response.config
|
|
259
|
+
this.response = response
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export class TimeoutError extends Error {
|
|
264
|
+
readonly code = 'ETIMEDOUT'
|
|
265
|
+
|
|
266
|
+
constructor(timeout: number) {
|
|
267
|
+
super(`HTTP 请求超过 ${timeout}ms 未完成`)
|
|
268
|
+
this.name = 'TimeoutError'
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export const HTTP_KEY = createInjectionKey<HTTPClient>('vobs.http')
|
|
273
|
+
|
|
274
|
+
export interface HTTPPluginOptions extends HTTPClientOptions {
|
|
275
|
+
client?: HTTPClient
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function createHTTPClient(options: HTTPClientOptions = {}): HTTPClient {
|
|
279
|
+
const defaults = normalizeClientOptions(options)
|
|
280
|
+
const requestInterceptors = createInterceptors<RequestConfig>()
|
|
281
|
+
const responseInterceptors = createInterceptors<HTTPResponse<unknown>>()
|
|
282
|
+
const adapter = options.adapter ?? createAxiosAdapter()
|
|
283
|
+
const limiter = createConcurrencyLimiter(options.concurrency ?? Infinity)
|
|
284
|
+
const pending = new Map<string, Promise<HTTPResponse<unknown>>>()
|
|
285
|
+
|
|
286
|
+
async function request<T>(input: RequestOptions): Promise<HTTPResponse<T>> {
|
|
287
|
+
const initial = normalizeRequest(input, defaults)
|
|
288
|
+
const dedupeKey = getDedupeKey(initial, options.dedupe ?? false)
|
|
289
|
+
if (dedupeKey) {
|
|
290
|
+
const existing = pending.get(dedupeKey)
|
|
291
|
+
if (existing) return existing as Promise<HTTPResponse<T>>
|
|
292
|
+
const shared = executeDebugRequest(initial)
|
|
293
|
+
pending.set(dedupeKey, shared)
|
|
294
|
+
void shared.finally(() => pending.delete(dedupeKey)).catch(() => undefined)
|
|
295
|
+
return shared as Promise<HTTPResponse<T>>
|
|
296
|
+
}
|
|
297
|
+
return executeDebugRequest(initial) as Promise<HTTPResponse<T>>
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function executeDebugRequest(initial: RequestConfig): Promise<HTTPResponse<unknown>> {
|
|
301
|
+
const id = nextHTTPDebugId++
|
|
302
|
+
const startedAt = Date.now()
|
|
303
|
+
const context = {
|
|
304
|
+
...getRuntimeDebugContext(),
|
|
305
|
+
...initial.debugContext
|
|
306
|
+
}
|
|
307
|
+
emitHTTPDebug({
|
|
308
|
+
id,
|
|
309
|
+
phase: 'start',
|
|
310
|
+
status: 'loading',
|
|
311
|
+
url: initial.url,
|
|
312
|
+
method: initial.method,
|
|
313
|
+
headers: debugHeaders(initial.headers),
|
|
314
|
+
requestBody: debugValue(initial.body),
|
|
315
|
+
startedAt,
|
|
316
|
+
attempt: 0,
|
|
317
|
+
retries: 0,
|
|
318
|
+
context: Object.keys(context).length > 0 ? context : undefined
|
|
319
|
+
})
|
|
320
|
+
return executeRequest(initial, id).then(response => {
|
|
321
|
+
const endedAt = Date.now()
|
|
322
|
+
emitHTTPDebug({
|
|
323
|
+
id,
|
|
324
|
+
phase: 'end',
|
|
325
|
+
status: 'success',
|
|
326
|
+
url: response.config.url,
|
|
327
|
+
method: response.config.method,
|
|
328
|
+
headers: debugHeaders(response.config.headers),
|
|
329
|
+
requestBody: debugValue(response.config.body),
|
|
330
|
+
startedAt,
|
|
331
|
+
endedAt,
|
|
332
|
+
duration: endedAt - startedAt,
|
|
333
|
+
attempt: 0,
|
|
334
|
+
retries: 0,
|
|
335
|
+
responseStatus: response.status,
|
|
336
|
+
responseBody: debugValue(response.data),
|
|
337
|
+
context: Object.keys(context).length > 0 ? context : undefined
|
|
338
|
+
})
|
|
339
|
+
return response
|
|
340
|
+
}, error => {
|
|
341
|
+
const endedAt = Date.now()
|
|
342
|
+
const cancelled = isAbortError(error)
|
|
343
|
+
emitHTTPDebug({
|
|
344
|
+
id,
|
|
345
|
+
phase: 'end',
|
|
346
|
+
status: cancelled ? 'cancelled' : 'error',
|
|
347
|
+
url: initial.url,
|
|
348
|
+
method: initial.method,
|
|
349
|
+
headers: debugHeaders(initial.headers),
|
|
350
|
+
requestBody: debugValue(initial.body),
|
|
351
|
+
startedAt,
|
|
352
|
+
endedAt,
|
|
353
|
+
duration: endedAt - startedAt,
|
|
354
|
+
attempt: 0,
|
|
355
|
+
retries: 0,
|
|
356
|
+
responseStatus: error instanceof HTTPError ? error.status : undefined,
|
|
357
|
+
responseBody: error instanceof HTTPError ? debugValue(error.data) : undefined,
|
|
358
|
+
error: { name: error instanceof Error ? error.name : 'Error', message: error instanceof Error ? error.message : String(error) },
|
|
359
|
+
context: Object.keys(context).length > 0 ? context : undefined
|
|
360
|
+
})
|
|
361
|
+
throw error
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function executeRequest(initial: RequestConfig, debugId?: number): Promise<HTTPResponse<unknown>> {
|
|
366
|
+
const requestChain = requestInterceptors.handlers()
|
|
367
|
+
const responseChain = responseInterceptors.handlers().reverse()
|
|
368
|
+
|
|
369
|
+
let chain: Promise<unknown> = Promise.resolve(initial)
|
|
370
|
+
for (const handler of requestChain) {
|
|
371
|
+
chain = chain.then(handler.onFulfilled, handler.onRejected)
|
|
372
|
+
}
|
|
373
|
+
chain = chain.then(config => limiter(() => executeWithRetry(config as RequestConfig, adapter, debugId)))
|
|
374
|
+
for (const handler of responseChain) {
|
|
375
|
+
chain = chain.then(handler.onFulfilled, handler.onRejected)
|
|
376
|
+
}
|
|
377
|
+
return chain as Promise<HTTPResponse<unknown>>
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function method<T>(methodName: HTTPMethod, url: string, input: Omit<RequestOptions, 'url' | 'method' | 'body'> = {}): Promise<HTTPResponse<T>> {
|
|
381
|
+
return request<T>({ ...input, url, method: methodName })
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function methodWithBody<T>(methodName: HTTPMethod, url: string, body: unknown, input: Omit<RequestOptions, 'url' | 'method' | 'body'> = {}): Promise<HTTPResponse<T>> {
|
|
385
|
+
return request<T>({ ...input, url, method: methodName, body })
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const client: HTTPClient = {
|
|
389
|
+
interceptors: {
|
|
390
|
+
request: requestInterceptors,
|
|
391
|
+
response: responseInterceptors
|
|
392
|
+
},
|
|
393
|
+
request,
|
|
394
|
+
get: (url, input) => method('GET', url, input),
|
|
395
|
+
delete: (url, input) => method('DELETE', url, input),
|
|
396
|
+
head: (url, input) => method('HEAD', url, input),
|
|
397
|
+
post: (url, body, input) => methodWithBody('POST', url, body, input),
|
|
398
|
+
put: (url, body, input) => methodWithBody('PUT', url, body, input),
|
|
399
|
+
patch: (url, body, input) => methodWithBody('PATCH', url, body, input)
|
|
400
|
+
}
|
|
401
|
+
return client
|
|
402
|
+
|
|
403
|
+
async function executeWithRetry(config: RequestConfig, requestAdapter: HTTPAdapter, debugId?: number): Promise<HTTPResponse<unknown>> {
|
|
404
|
+
const controller = new AbortController()
|
|
405
|
+
const inputSignal = config.signal ?? config.state
|
|
406
|
+
let timedOut = false
|
|
407
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
408
|
+
const abortFromInput = (): void => controller.abort(inputSignal?.reason)
|
|
409
|
+
|
|
410
|
+
if (inputSignal) {
|
|
411
|
+
if (inputSignal.aborted) controller.abort(inputSignal.reason)
|
|
412
|
+
else inputSignal.addEventListener('abort', abortFromInput, { once: true })
|
|
413
|
+
}
|
|
414
|
+
if (config.timeout !== undefined && config.timeout > 0) {
|
|
415
|
+
timeoutId = setTimeout(() => {
|
|
416
|
+
timedOut = true
|
|
417
|
+
controller.abort()
|
|
418
|
+
}, config.timeout)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const adapterConfig: RequestConfig = { ...config, signal: controller.signal }
|
|
422
|
+
let attempt = 0
|
|
423
|
+
try {
|
|
424
|
+
while (true) {
|
|
425
|
+
try {
|
|
426
|
+
const result = await requestAdapter(adapterConfig)
|
|
427
|
+
const response = isHTTPResponse(result)
|
|
428
|
+
? result
|
|
429
|
+
: await parseResponse(result, adapterConfig)
|
|
430
|
+
if (response.status < 200 || response.status >= 300) throw new HTTPError(response)
|
|
431
|
+
return response
|
|
432
|
+
} catch (error) {
|
|
433
|
+
if (timedOut) throw new TimeoutError(config.timeout!)
|
|
434
|
+
if (inputSignal?.aborted || isAbortError(error)) throw error
|
|
435
|
+
const nextAttempt = attempt + 1
|
|
436
|
+
if (nextAttempt > (config.retry ?? 0)) throw toError(error)
|
|
437
|
+
const shouldRetry = config.shouldRetry
|
|
438
|
+
? await config.shouldRetry(toError(error), nextAttempt)
|
|
439
|
+
: isRetryable(error)
|
|
440
|
+
if (!shouldRetry) throw toError(error)
|
|
441
|
+
attempt = nextAttempt
|
|
442
|
+
const retryContext = {
|
|
443
|
+
...getRuntimeDebugContext(),
|
|
444
|
+
...config.debugContext
|
|
445
|
+
}
|
|
446
|
+
emitHTTPDebug({
|
|
447
|
+
id: debugId ?? 0,
|
|
448
|
+
phase: 'retry',
|
|
449
|
+
status: 'retrying',
|
|
450
|
+
url: config.url,
|
|
451
|
+
method: config.method,
|
|
452
|
+
headers: debugHeaders(config.headers),
|
|
453
|
+
requestBody: debugValue(config.body),
|
|
454
|
+
startedAt: Date.now(),
|
|
455
|
+
attempt,
|
|
456
|
+
retries: attempt,
|
|
457
|
+
error: { name: toError(error).name, message: toError(error).message },
|
|
458
|
+
context: Object.keys(retryContext).length > 0 ? retryContext : undefined
|
|
459
|
+
})
|
|
460
|
+
const delay = resolveRetryDelay(config.retryDelay ?? 0, attempt, toError(error))
|
|
461
|
+
if (delay > 0) await wait(delay)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
} finally {
|
|
465
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId)
|
|
466
|
+
inputSignal?.removeEventListener('abort', abortFromInput)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function debugHeaders(headers: HTTPHeaders): HTTPHeaders {
|
|
472
|
+
const safe: HTTPHeaders = {}
|
|
473
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
474
|
+
if (/authorization|cookie|token|password|secret|api[-_]?key/i.test(key)) continue
|
|
475
|
+
safe[key] = value
|
|
476
|
+
}
|
|
477
|
+
return safe
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function debugValue(value: unknown, depth = 0): unknown {
|
|
481
|
+
if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value
|
|
482
|
+
if (value === undefined) return undefined
|
|
483
|
+
if (depth >= 2) return '[MaxDepth]'
|
|
484
|
+
if (typeof value === 'bigint') return `${value}n`
|
|
485
|
+
if (typeof value === 'function') return `[Function ${value.name || 'anonymous'}]`
|
|
486
|
+
if (typeof value !== 'object') return String(value)
|
|
487
|
+
if (Array.isArray(value)) return value.slice(0, 20).map(item => debugValue(item, depth + 1))
|
|
488
|
+
try {
|
|
489
|
+
return Object.fromEntries(Object.entries(value).slice(0, 30).map(([key, item]) => [key, debugValue(item, depth + 1)]))
|
|
490
|
+
} catch {
|
|
491
|
+
return '[Uninspectable]'
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function httpPlugin(options: HTTPPluginOptions = {}): VobsPlugin {
|
|
496
|
+
return {
|
|
497
|
+
name: '@vobs/http',
|
|
498
|
+
version: '0.1.0',
|
|
499
|
+
install(context) {
|
|
500
|
+
const client = options.client ?? createHTTPClient(options)
|
|
501
|
+
context.provide(HTTP_KEY, client)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function createInterceptors<T>(): InterceptorManager<T> & {
|
|
507
|
+
handlers(): Array<{ onFulfilled: (value: unknown) => unknown; onRejected: (error: unknown) => unknown }>
|
|
508
|
+
} {
|
|
509
|
+
type Handler = {
|
|
510
|
+
onFulfilled: (value: unknown) => unknown
|
|
511
|
+
onRejected: (error: unknown) => unknown
|
|
512
|
+
}
|
|
513
|
+
const entries: Array<Handler | null> = []
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
use(
|
|
517
|
+
onFulfilled?: (value: T) => unknown | PromiseLike<unknown>,
|
|
518
|
+
onRejected?: (error: unknown) => unknown | PromiseLike<unknown>
|
|
519
|
+
) {
|
|
520
|
+
entries.push({
|
|
521
|
+
onFulfilled: (value: unknown) => onFulfilled ? onFulfilled(value as T) : value,
|
|
522
|
+
onRejected: (error: unknown) => onRejected ? onRejected(error) : Promise.reject(error)
|
|
523
|
+
})
|
|
524
|
+
return entries.length - 1
|
|
525
|
+
},
|
|
526
|
+
eject(id) {
|
|
527
|
+
if (id >= 0 && id < entries.length) entries[id] = null
|
|
528
|
+
},
|
|
529
|
+
clear() {
|
|
530
|
+
entries.fill(null)
|
|
531
|
+
},
|
|
532
|
+
handlers() {
|
|
533
|
+
return entries.filter((entry): entry is Handler => entry !== null)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function normalizeClientOptions(options: HTTPClientOptions): Required<Pick<HTTPClientOptions, 'baseURL' | 'headers' | 'timeout' | 'retry' | 'retryDelay'>> & Pick<HTTPClientOptions, 'shouldRetry'> {
|
|
539
|
+
return {
|
|
540
|
+
baseURL: options.baseURL ?? '',
|
|
541
|
+
headers: options.headers ?? {},
|
|
542
|
+
timeout: validateTimeout(options.timeout ?? 0),
|
|
543
|
+
retry: validateRetry(options.retry ?? 0),
|
|
544
|
+
retryDelay: options.retryDelay ?? 0,
|
|
545
|
+
shouldRetry: options.shouldRetry
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function createConcurrencyLimiter(limit: number): ConcurrencyLimiter {
|
|
550
|
+
if (limit !== Infinity && (!Number.isFinite(limit) || limit < 1 || !Number.isInteger(limit))) {
|
|
551
|
+
throw new Error('HTTP: concurrency 必须是大于等于 1 的整数')
|
|
552
|
+
}
|
|
553
|
+
let active = 0
|
|
554
|
+
const queue: Array<() => void> = []
|
|
555
|
+
|
|
556
|
+
return <T>(task: () => T | PromiseLike<T>): Promise<T> => new Promise<T>((resolve, reject) => {
|
|
557
|
+
const run = (): void => {
|
|
558
|
+
active++
|
|
559
|
+
Promise.resolve().then(task).then(resolve, reject).finally(() => {
|
|
560
|
+
active--
|
|
561
|
+
queue.shift()?.()
|
|
562
|
+
})
|
|
563
|
+
}
|
|
564
|
+
if (active < limit) run()
|
|
565
|
+
else queue.push(run)
|
|
566
|
+
})
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function getDedupeKey(config: RequestConfig, defaultEnabled: boolean): string | undefined {
|
|
570
|
+
if (config.dedupe === false) return undefined
|
|
571
|
+
if (config.dedupeKey) return config.dedupeKey
|
|
572
|
+
if (!(config.dedupe ?? defaultEnabled)) return undefined
|
|
573
|
+
if (config.method !== 'GET' && config.method !== 'HEAD') return undefined
|
|
574
|
+
return `${config.method} ${config.url}`
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function normalizeRequest(input: RequestOptions, defaults: ReturnType<typeof normalizeClientOptions>): RequestConfig {
|
|
578
|
+
if (!input.url) throw new Error('HTTP: url 不能为空')
|
|
579
|
+
const method = (input.method ?? 'GET').toUpperCase() as HTTPMethod
|
|
580
|
+
const timeout = validateTimeout(input.timeout ?? defaults.timeout)
|
|
581
|
+
const retry = validateRetry(input.retry ?? defaults.retry)
|
|
582
|
+
const retryDelay = input.retryDelay ?? defaults.retryDelay
|
|
583
|
+
const signal = input.signal ?? input.state
|
|
584
|
+
if (input.signal && input.state && input.signal !== input.state) {
|
|
585
|
+
throw new Error('HTTP: signal 和 state 不能同时指向不同的 AbortSignal')
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
...input,
|
|
589
|
+
url: appendParams(resolveURL(input.url, input.baseURL ?? defaults.baseURL), input.params),
|
|
590
|
+
method,
|
|
591
|
+
headers: toHeaders(defaults.headers, input.headers),
|
|
592
|
+
timeout,
|
|
593
|
+
retry,
|
|
594
|
+
retryDelay,
|
|
595
|
+
shouldRetry: input.shouldRetry ?? defaults.shouldRetry,
|
|
596
|
+
signal
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function fetchAdapter(config: RequestConfig): Promise<Response> {
|
|
601
|
+
if (typeof fetch !== 'function') throw new Error('HTTP: 当前环境没有可用的 fetch')
|
|
602
|
+
const headers = new Headers(config.headers)
|
|
603
|
+
const body = encodeBody(config.body, headers, config.method)
|
|
604
|
+
return fetch(config.url, {
|
|
605
|
+
method: config.method,
|
|
606
|
+
headers,
|
|
607
|
+
body,
|
|
608
|
+
signal: config.signal,
|
|
609
|
+
cache: config.cache,
|
|
610
|
+
credentials: config.credentials,
|
|
611
|
+
mode: config.mode
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function parseResponse(response: Response, config: RequestConfig): Promise<HTTPResponse<unknown>> {
|
|
616
|
+
const data = config.responseType === 'response'
|
|
617
|
+
? response
|
|
618
|
+
: await parseBody(response, config.responseType, hasHandledProgress(response) ? undefined : config.onDownloadProgress)
|
|
619
|
+
return {
|
|
620
|
+
data,
|
|
621
|
+
status: response.status,
|
|
622
|
+
statusText: response.statusText,
|
|
623
|
+
headers: response.headers,
|
|
624
|
+
config,
|
|
625
|
+
raw: response
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const builtInAxiosRequest: AxiosRequest = async <T = unknown>(config: AxiosRequestConfigLike) => {
|
|
630
|
+
const responseType = config.responseType === 'response' ? 'arraybuffer' : toAxiosResponseType(config.responseType)
|
|
631
|
+
const response = await axios.request<T>({
|
|
632
|
+
url: config.url,
|
|
633
|
+
method: config.method,
|
|
634
|
+
headers: config.headers,
|
|
635
|
+
data: config.method === 'GET' || config.method === 'HEAD' ? undefined : config.body,
|
|
636
|
+
signal: config.signal,
|
|
637
|
+
timeout: config.timeout,
|
|
638
|
+
responseType,
|
|
639
|
+
withCredentials: toAxiosCredentials(config.credentials),
|
|
640
|
+
validateStatus: () => true,
|
|
641
|
+
adapter: 'fetch',
|
|
642
|
+
fetchOptions: compactFetchOptions(config),
|
|
643
|
+
onUploadProgress: event => config.onUploadProgress?.(toAxiosProgress(event.loaded, event.total)),
|
|
644
|
+
onDownloadProgress: event => config.onDownloadProgress?.(toAxiosProgress(event.loaded, event.total))
|
|
645
|
+
})
|
|
646
|
+
const raw = config.responseType === 'response'
|
|
647
|
+
? toRawResponse(response.data, response.status, response.statusText, response.headers as unknown as HeadersInit)
|
|
648
|
+
: null
|
|
649
|
+
return {
|
|
650
|
+
data: (raw ?? response.data) as T,
|
|
651
|
+
status: response.status,
|
|
652
|
+
statusText: response.statusText,
|
|
653
|
+
headers: (typeof response.headers?.toJSON === 'function' ? response.headers.toJSON() : response.headers) as HeadersInit,
|
|
654
|
+
raw
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function toAxiosCredentials(credentials?: RequestCredentials): boolean | undefined {
|
|
659
|
+
if (credentials === 'include') return true
|
|
660
|
+
if (credentials === 'omit') return false
|
|
661
|
+
return undefined
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function toRawResponse(data: unknown, status: number, statusText: string | undefined, headers: HeadersInit | undefined): Response {
|
|
665
|
+
if (typeof Response === 'undefined') throw new Error('HTTP: 当前环境没有可用的 Response')
|
|
666
|
+
const body = status === 204 || status === 205 ? null : data as BodyInit | null | undefined
|
|
667
|
+
return new Response(body, {
|
|
668
|
+
status,
|
|
669
|
+
statusText: statusText ?? '',
|
|
670
|
+
headers: new Headers(headers)
|
|
671
|
+
})
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function toAxiosResponseType(responseType?: HTTPResponseType): 'arraybuffer' | 'blob' | 'json' | 'text' | undefined {
|
|
675
|
+
if (responseType === 'arrayBuffer') return 'arraybuffer'
|
|
676
|
+
if (responseType === 'response') return undefined
|
|
677
|
+
return responseType
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function compactFetchOptions(config: RequestConfig): Record<string, unknown> | undefined {
|
|
681
|
+
const options = {
|
|
682
|
+
cache: config.cache,
|
|
683
|
+
credentials: config.credentials,
|
|
684
|
+
mode: config.mode
|
|
685
|
+
}
|
|
686
|
+
const entries = Object.entries(options).filter(([, value]) => value !== undefined)
|
|
687
|
+
return entries.length === 0 ? undefined : Object.fromEntries(entries)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function toAxiosProgress(loaded: number, total: number | undefined): HTTPProgress {
|
|
691
|
+
return {
|
|
692
|
+
loaded,
|
|
693
|
+
total,
|
|
694
|
+
percent: total && total > 0 ? loaded / total * 100 : undefined
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function parseBody(
|
|
699
|
+
response: Response,
|
|
700
|
+
responseType?: HTTPResponseType,
|
|
701
|
+
onDownloadProgress?: HTTPProgressHandler
|
|
702
|
+
): Promise<unknown> {
|
|
703
|
+
if (response.status === 204 || response.status === 205) return null
|
|
704
|
+
const bytes = onDownloadProgress && response.body
|
|
705
|
+
? await readResponseBytes(response, onDownloadProgress)
|
|
706
|
+
: undefined
|
|
707
|
+
if (responseType === 'blob') return bytes ? new Blob([bytes as unknown as BlobPart]) : response.blob()
|
|
708
|
+
if (responseType === 'arrayBuffer') return bytes ? bytes.buffer : response.arrayBuffer()
|
|
709
|
+
const text = bytes ? new TextDecoder().decode(bytes) : await response.text()
|
|
710
|
+
if (responseType === 'text') return text
|
|
711
|
+
if (!text) return null
|
|
712
|
+
if (responseType === 'json' || response.headers.get('content-type')?.includes('json')) {
|
|
713
|
+
try {
|
|
714
|
+
return JSON.parse(text)
|
|
715
|
+
} catch {
|
|
716
|
+
throw new Error('HTTP: 响应不是有效 JSON')
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return text
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function readResponseBytes(response: Response, onProgress: HTTPProgressHandler): Promise<Uint8Array> {
|
|
723
|
+
const reader = response.body!.getReader()
|
|
724
|
+
const chunks: Uint8Array[] = []
|
|
725
|
+
const totalHeader = response.headers.get('content-length')
|
|
726
|
+
const parsedTotal = totalHeader ? Number(totalHeader) : NaN
|
|
727
|
+
const total = Number.isFinite(parsedTotal) && parsedTotal >= 0 ? parsedTotal : undefined
|
|
728
|
+
let loaded = 0
|
|
729
|
+
while (true) {
|
|
730
|
+
const next = await reader.read()
|
|
731
|
+
if (next.done) break
|
|
732
|
+
chunks.push(next.value)
|
|
733
|
+
loaded += next.value.byteLength
|
|
734
|
+
onProgress(toProgress(loaded, total))
|
|
735
|
+
}
|
|
736
|
+
const result = new Uint8Array(loaded)
|
|
737
|
+
let offset = 0
|
|
738
|
+
for (const chunk of chunks) {
|
|
739
|
+
result.set(chunk, offset)
|
|
740
|
+
offset += chunk.byteLength
|
|
741
|
+
}
|
|
742
|
+
return result
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function isHTTPResponse(value: unknown): value is HTTPResponse<unknown> {
|
|
746
|
+
return typeof value === 'object' && value !== null && 'data' in value && 'status' in value && 'config' in value
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function isResponse(value: unknown): value is Response {
|
|
750
|
+
return typeof Response !== 'undefined' && value instanceof Response
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function hasHandledProgress(response: Response): boolean {
|
|
754
|
+
return Boolean((response as Response & { [PROGRESS_HANDLED]?: boolean })[PROGRESS_HANDLED])
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function encodeBody(body: unknown, headers: Headers, method: HTTPMethod): BodyInit | undefined {
|
|
758
|
+
if (body === undefined || body === null || method === 'GET' || method === 'HEAD') return undefined
|
|
759
|
+
if (typeof body === 'string' || body instanceof Blob || body instanceof FormData
|
|
760
|
+
|| body instanceof ArrayBuffer || body instanceof URLSearchParams) return body as BodyInit
|
|
761
|
+
if (!headers.has('content-type')) headers.set('content-type', 'application/json')
|
|
762
|
+
return JSON.stringify(body)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function toHeaders(...inputs: Array<HeadersInit | undefined>): HTTPHeaders {
|
|
766
|
+
const headers = new Headers()
|
|
767
|
+
for (const input of inputs) {
|
|
768
|
+
if (!input) continue
|
|
769
|
+
new Headers(input).forEach((value, key) => headers.set(key, value))
|
|
770
|
+
}
|
|
771
|
+
const result: HTTPHeaders = {}
|
|
772
|
+
headers.forEach((value, key) => { result[key] = value })
|
|
773
|
+
return result
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function resolveURL(url: string, baseURL: string): string {
|
|
777
|
+
if (!baseURL || /^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith('//')) return url
|
|
778
|
+
if (!baseURL) return url
|
|
779
|
+
return `${baseURL.replace(/\/$/, '')}/${url.replace(/^\//, '')}`
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function appendParams(url: string, params?: Record<string, unknown> | URLSearchParams): string {
|
|
783
|
+
if (!params) return url
|
|
784
|
+
const query = params instanceof URLSearchParams ? params : toSearchParams(params)
|
|
785
|
+
const serialized = query.toString()
|
|
786
|
+
if (!serialized) return url
|
|
787
|
+
return `${url}${url.includes('?') ? '&' : '?'}${serialized}`
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function toSearchParams(params: Record<string, unknown>): URLSearchParams {
|
|
791
|
+
const search = new URLSearchParams()
|
|
792
|
+
for (const [key, value] of Object.entries(params)) {
|
|
793
|
+
if (value === undefined || value === null) continue
|
|
794
|
+
if (Array.isArray(value)) {
|
|
795
|
+
for (const item of value) search.append(key, String(item))
|
|
796
|
+
} else if (typeof value === 'object') {
|
|
797
|
+
search.set(key, JSON.stringify(value))
|
|
798
|
+
} else {
|
|
799
|
+
search.set(key, String(value))
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return search
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function isRetryable(error: unknown): boolean {
|
|
806
|
+
return error instanceof HTTPError
|
|
807
|
+
? error.status === 429 || error.status >= 500
|
|
808
|
+
: !isAbortError(error)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function isAbortError(error: unknown): boolean {
|
|
812
|
+
return Boolean(error) && typeof error === 'object'
|
|
813
|
+
&& ((error as { name?: unknown }).name === 'AbortError'
|
|
814
|
+
|| (error as { code?: unknown }).code === 'ERR_CANCELED')
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function resolveRetryDelay(retryDelay: RetryDelay, attempt: number, error: Error): number {
|
|
818
|
+
const delay = typeof retryDelay === 'function' ? retryDelay(attempt, error) : retryDelay
|
|
819
|
+
if (!Number.isFinite(delay) || delay < 0) throw new Error('HTTP: retryDelay 必须是大于等于 0 的有限数字')
|
|
820
|
+
return delay
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function validateRetry(retry: number): number {
|
|
824
|
+
if (!Number.isInteger(retry) || retry < 0) throw new Error('HTTP: retry 必须是大于等于 0 的整数')
|
|
825
|
+
return retry
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function validateTimeout(timeout: number): number {
|
|
829
|
+
if (!Number.isFinite(timeout) || timeout < 0) throw new Error('HTTP: timeout 必须是大于等于 0 的有限数字')
|
|
830
|
+
return timeout
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function toError(error: unknown): Error {
|
|
834
|
+
return error instanceof Error ? error : new Error(String(error))
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function wait(delay: number): Promise<void> {
|
|
838
|
+
return new Promise(resolve => setTimeout(resolve, delay))
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function toProgress(loaded: number, total: number | undefined): HTTPProgress {
|
|
842
|
+
return {
|
|
843
|
+
loaded,
|
|
844
|
+
total,
|
|
845
|
+
percent: total && total > 0 ? loaded / total * 100 : undefined
|
|
846
|
+
}
|
|
847
|
+
}
|