@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/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
+ }