import { createInjectionKey, type VobsPlugin } from '@vobs/vobs' import axios from 'axios' export { createSSE, createWebSocket } from './stream' import { emitHTTPDebug } from './debug' import { getRuntimeDebugContext } from '@vobs/runtime' export { emitHTTPDebug, getHTTPDebugHooks, setHTTPDebugHooks, subscribeHTTPDebug } from './debug' export type { HTTPDebugCacheStatus, HTTPDebugContext, HTTPDebugHooks, HTTPDebugRequest, HTTPDebugStatus } from './debug' export type { SSEClient, SSEConstructor, SSEOptions, WebSocketClient, WebSocketConstructor, WebSocketEventListener, WebSocketEventName, WebSocketOptions, WebSocketState } from './stream' export type HTTPMethod = 'DELETE' | 'GET' | 'HEAD' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT' export type HTTPResponseType = 'arrayBuffer' | 'blob' | 'json' | 'response' | 'text' export type HTTPHeaders = Record export type RetryDelay = number | ((attempt: number, error: Error) => number) export interface HTTPProgress { readonly loaded: number readonly total: number | undefined readonly percent: number | undefined } export type HTTPProgressHandler = (progress: HTTPProgress) => void const PROGRESS_HANDLED = Symbol('vobs.http.progress-handled') let nextHTTPDebugId = 1 export interface RequestOptions { url: string method?: HTTPMethod baseURL?: string headers?: HeadersInit params?: Record | URLSearchParams body?: unknown signal?: AbortSignal /** @deprecated Use signal. */ state?: AbortSignal timeout?: number retry?: number retryDelay?: RetryDelay shouldRetry?: (error: Error, attempt: number) => boolean | PromiseLike cache?: RequestCache credentials?: RequestCredentials mode?: RequestMode responseType?: HTTPResponseType onUploadProgress?: HTTPProgressHandler onDownloadProgress?: HTTPProgressHandler /** Share an in-flight request with the same dedupe key. */ dedupe?: boolean dedupeKey?: string /** Optional context copied into DevTools request traces. */ debugContext?: import('./debug').HTTPDebugContext } export interface RequestConfig extends Omit { readonly method: HTTPMethod headers: HTTPHeaders readonly url: string } export interface HTTPResponse { readonly data: T readonly status: number readonly statusText: string readonly headers: Headers readonly config: RequestConfig readonly raw: Response | null } /** Adapt an HTTP request to the fetcher contract used by Resource. */ export type HTTPResourceRequest = ( signal: AbortSignal ) => HTTPResponse | PromiseLike> export function toResourceFetcher(request: HTTPResourceRequest): (signal: AbortSignal) => Promise { return signal => Promise.resolve() .then(() => request(signal)) .then(response => response.data) } export type HTTPAdapter = ( config: RequestConfig ) => Response | HTTPResponse | PromiseLike> export interface AxiosResponseLike { readonly data: T readonly status: number readonly statusText?: string readonly headers?: HeadersInit readonly raw?: Response | null } export type AxiosRequestConfigLike = RequestConfig & { readonly data?: unknown } export type AxiosRequest = (config: AxiosRequestConfigLike) => PromiseLike> /** Bridges an Axios request function while preserving HTTPResponse.data typing. */ export function createAxiosAdapter(request: AxiosRequest = builtInAxiosRequest): HTTPAdapter { return async config => { // normalizeRequest has already serialized params into config.url. Omit // params before invoking a custom Axios request so spreading this config // into axios.request cannot append the query string a second time. const { params: _params, ...requestConfig } = config const response = await request({ ...requestConfig, data: config.body }) return { data: response.data, status: response.status, statusText: response.statusText ?? '', headers: new Headers(response.headers), config, raw: response.raw ?? null } } } /** * Creates an adapter for deterministic local handlers and test transports. * Returning a Response or HTTPResponse keeps full control over status and * headers; any other value becomes a successful 200 payload. */ export function createMockAdapter( handler: (config: RequestConfig) => unknown | PromiseLike ): HTTPAdapter { return async config => { const result = await handler(config) if (isResponse(result) || isHTTPResponse(result)) return result return { data: result, status: 200, statusText: 'OK', headers: new Headers(), config, raw: null } } } /** XMLHttpRequest adapter for upload and download progress events. */ export function createXHRAdapter(): HTTPAdapter { return config => new Promise((resolve, reject) => { if (typeof XMLHttpRequest === 'undefined') { reject(new Error('HTTP: 当前环境没有可用的 XMLHttpRequest')) return } const xhr = new XMLHttpRequest() xhr.open(config.method, config.url, true) if (config.responseType === 'arrayBuffer') xhr.responseType = 'arraybuffer' else if (config.responseType === 'blob') xhr.responseType = 'blob' const signal = config.signal ?? config.state const abort = (): void => xhr.abort() if (signal?.aborted) { reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })) return } const cleanup = (): void => signal?.removeEventListener('abort', abort) signal?.addEventListener('abort', abort, { once: true }) xhr.upload.onprogress = event => config.onUploadProgress?.(toProgress(event.loaded, event.lengthComputable ? event.total : undefined)) xhr.onprogress = event => config.onDownloadProgress?.(toProgress(event.loaded, event.lengthComputable ? event.total : undefined)) xhr.onload = () => { cleanup() const headers = new Headers() xhr.getAllResponseHeaders().trim().split(/[\r\n]+/).forEach(line => { const separator = line.indexOf(':') if (separator > 0) headers.set(line.slice(0, separator).trim(), line.slice(separator + 1).trim()) }) const body = config.responseType === 'arrayBuffer' || config.responseType === 'blob' ? xhr.response : xhr.responseText const response = new Response(body, { status: xhr.status, statusText: xhr.statusText, headers }) Object.defineProperty(response, PROGRESS_HANDLED, { value: true }) resolve(response) } xhr.onerror = () => { cleanup(); reject(new Error('HTTP: XMLHttpRequest 网络错误')) } xhr.onabort = () => { cleanup() reject(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })) } try { const headers = new Headers(config.headers) const body = encodeBody(config.body, headers, config.method) headers.forEach((value, key) => xhr.setRequestHeader(key, value)) xhr.send((body ?? null) as Document | XMLHttpRequestBodyInit | null) } catch (error) { cleanup() reject(error) } }) } /** Explicit fetch transport for applications that do not want the built-in Axios transport. */ export function createFetchAdapter(): HTTPAdapter { return fetchAdapter } export interface HTTPClientOptions { baseURL?: string headers?: HeadersInit timeout?: number adapter?: HTTPAdapter retry?: number retryDelay?: RetryDelay shouldRetry?: (error: Error, attempt: number) => boolean | PromiseLike concurrency?: number dedupe?: boolean } export interface InterceptorManager { use( onFulfilled?: (value: T) => TResult | PromiseLike, onRejected?: (error: unknown) => TResult | PromiseLike ): number eject(id: number): void clear(): void } export interface HTTPInterceptors { readonly request: InterceptorManager readonly response: InterceptorManager> } export interface HTTPClient { readonly interceptors: HTTPInterceptors request(options: RequestOptions): Promise> get(url: string, options?: Omit): Promise> delete(url: string, options?: Omit): Promise> head(url: string, options?: Omit): Promise> post(url: string, body?: unknown, options?: Omit): Promise> put(url: string, body?: unknown, options?: Omit): Promise> patch(url: string, body?: unknown, options?: Omit): Promise> } export interface ConcurrencyLimiter { (task: () => T | PromiseLike): Promise } export class HTTPError extends Error { readonly status: number readonly statusText: string readonly data: unknown readonly config: RequestConfig readonly response: HTTPResponse constructor(response: HTTPResponse) { super(`HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}`) this.name = 'HTTPError' this.status = response.status this.statusText = response.statusText this.data = response.data this.config = response.config this.response = response } } export class TimeoutError extends Error { readonly code = 'ETIMEDOUT' /** * 超时时手上那个错误。 * * 整链预算到点会把当前错误换成 TimeoutError —— 原来原错误就此丢掉(报告 P12:真实的 * ECONNREFUSED 变成了无 cause 的超时)。挂在这里,排查时还有线索。 */ readonly cause?: unknown constructor(timeout: number, cause?: unknown) { super(`HTTP 请求超过 ${timeout}ms 未完成`) this.name = 'TimeoutError' if (cause !== undefined) this.cause = cause } } export const HTTP_KEY = createInjectionKey('vobs.http') export interface HTTPPluginOptions extends HTTPClientOptions { client?: HTTPClient } export function createHTTPClient(options: HTTPClientOptions = {}): HTTPClient { const defaults = normalizeClientOptions(options) const requestInterceptors = createInterceptors() const responseInterceptors = createInterceptors>() const adapter = options.adapter ?? createAxiosAdapter() const limiter = createConcurrencyLimiter(options.concurrency ?? Infinity) const pending = new Map>>() async function request(input: RequestOptions): Promise> { const initial = normalizeRequest(input, defaults) const dedupeKey = getDedupeKey(initial, options.dedupe ?? false) if (dedupeKey) { const existing = pending.get(dedupeKey) if (existing) return existing as Promise> const shared = executeDebugRequest(initial) pending.set(dedupeKey, shared) void shared.finally(() => pending.delete(dedupeKey)).catch(() => undefined) return shared as Promise> } return executeDebugRequest(initial) as Promise> } function executeDebugRequest(initial: RequestConfig): Promise> { const id = nextHTTPDebugId++ const startedAt = Date.now() const context = { ...getRuntimeDebugContext(), ...initial.debugContext } emitHTTPDebug({ id, phase: 'start', status: 'loading', url: initial.url, method: initial.method, headers: debugHeaders(initial.headers), requestBody: debugValue(initial.body), startedAt, attempt: 0, retries: 0, context: Object.keys(context).length > 0 ? context : undefined }) return executeRequest(initial, id).then(response => { const endedAt = Date.now() emitHTTPDebug({ id, phase: 'end', status: 'success', url: response.config.url, method: response.config.method, headers: debugHeaders(response.config.headers), requestBody: debugValue(response.config.body), startedAt, endedAt, duration: endedAt - startedAt, attempt: 0, retries: 0, responseStatus: response.status, responseBody: debugValue(response.data), context: Object.keys(context).length > 0 ? context : undefined }) return response }, error => { const endedAt = Date.now() const cancelled = isAbortError(error) emitHTTPDebug({ id, phase: 'end', status: cancelled ? 'cancelled' : 'error', url: initial.url, method: initial.method, headers: debugHeaders(initial.headers), requestBody: debugValue(initial.body), startedAt, endedAt, duration: endedAt - startedAt, attempt: 0, retries: 0, responseStatus: error instanceof HTTPError ? error.status : undefined, responseBody: error instanceof HTTPError ? debugValue(error.data) : undefined, error: { name: error instanceof Error ? error.name : 'Error', message: error instanceof Error ? error.message : String(error) }, context: Object.keys(context).length > 0 ? context : undefined }) throw error }) } async function executeRequest(initial: RequestConfig, debugId?: number): Promise> { const requestChain = requestInterceptors.handlers() const responseChain = responseInterceptors.handlers().reverse() let chain: Promise = Promise.resolve(initial) for (const handler of requestChain) { chain = chain.then(handler.onFulfilled, handler.onRejected) } chain = chain.then(config => limiter(() => executeWithRetry(config as RequestConfig, adapter, debugId))) for (const handler of responseChain) { chain = chain.then(handler.onFulfilled, handler.onRejected) } return chain as Promise> } function method(methodName: HTTPMethod, url: string, input: Omit = {}): Promise> { return request({ ...input, url, method: methodName }) } function methodWithBody(methodName: HTTPMethod, url: string, body: unknown, input: Omit = {}): Promise> { return request({ ...input, url, method: methodName, body }) } const client: HTTPClient = { interceptors: { request: requestInterceptors, response: responseInterceptors }, request, get: (url, input) => method('GET', url, input), delete: (url, input) => method('DELETE', url, input), head: (url, input) => method('HEAD', url, input), post: (url, body, input) => methodWithBody('POST', url, body, input), put: (url, body, input) => methodWithBody('PUT', url, body, input), patch: (url, body, input) => methodWithBody('PATCH', url, body, input) } return client async function executeWithRetry(config: RequestConfig, requestAdapter: HTTPAdapter, debugId?: number): Promise> { const controller = new AbortController() const inputSignal = config.signal ?? config.state let timedOut = false let timeoutId: ReturnType | undefined const abortFromInput = (): void => controller.abort(inputSignal?.reason) if (inputSignal) { if (inputSignal.aborted) controller.abort(inputSignal.reason) else inputSignal.addEventListener('abort', abortFromInput, { once: true }) } if (config.timeout !== undefined && config.timeout > 0) { timeoutId = setTimeout(() => { timedOut = true controller.abort() }, config.timeout) } const adapterConfig: RequestConfig = { ...config, signal: controller.signal } /* * 取消与超时必须是**硬保证**,不能只把 signal 交给适配器就算了。 * * `HTTPAdapter` 是公开扩展点,完全可能不读 `config.signal`;原来这里直接 * `await requestAdapter(...)`,于是 abort 只是"建议":已 abort 的请求照样发出并返回 200, * `timeout: 10` 也要等适配器自己收敛才走到超时判定(实测 74ms 才成功)。适配器永不结算时 * 更糟:promise 永久 pending(并发闸被占死、去重同 key 永挂)。 * * 所以让 abort 与适配器赛跑 —— 请求 promise 必定收敛。 */ let rejectOnAbort: ((reason: unknown) => void) | undefined /** 适配器自己报出来的错误(输了赛跑时也能留下来当 cause)。 */ let lastError: unknown const aborted = new Promise((_, reject) => { rejectOnAbort = reject }) // 空 catch:没人接时不该变成 unhandledRejection(这个坑前面修过) aborted.catch(() => {}) const abortError = (): Error => { const reason = controller.signal.reason if (reason instanceof Error) return reason const error = new Error('Vobs HTTP: 请求已取消') error.name = 'AbortError' return error } const onAbort = (): void => { rejectOnAbort?.(abortError()) } if (controller.signal.aborted) onAbort() else controller.signal.addEventListener('abort', onAbort, { once: true }) let attempt = 0 try { while (true) { try { /* * 适配器的 promise 单独拿着:输了赛跑(abort 先到)时它稍后仍可能 reject —— * 没人接就会变成 unhandledRejection。这里先挂一个空 catch,同时把它记进 lastError, * 好在超时路径里作为 cause 保留下来(真实失败原因不该被丢掉)。 */ // 适配器允许同步返回,所以先归一成 promise const adapterPromise = Promise.resolve(requestAdapter(adapterConfig)) adapterPromise.catch(reason => { lastError = reason }) const result = await Promise.race([adapterPromise, aborted]) const response = isHTTPResponse(result) ? result : await parseResponse(result, adapterConfig) if (response.status < 200 || response.status >= 300) throw new HTTPError(response) return response } catch (error) { /* * 只挂**适配器自己报出来**的错误。 * * 超时的 abort 通常先到,此刻 lastError 还是空的 —— 那就**不挂 cause**, * 而不是把内部合成的 abort 错误塞进去当线索(那比没有更误导)。 * 真实错误若在超时前就已报出,会留在这里。 */ if (timedOut) throw new TimeoutError(config.timeout!, lastError) if (inputSignal?.aborted || isAbortError(error)) throw error const nextAttempt = attempt + 1 if (nextAttempt > (config.retry ?? 0)) throw toError(error) /* * 默认只对**幂等**方法自动重试。 * * 原来默认是 `isRetryable(error)`,完全不看方法 —— README 推荐的客户端级 `retry: 2` * 会让 POST 在 503 下真的发 3 次(报告实测)。服务端可能因此写入两次:静默重复提交。 * HTTP 客户端的惯例是"自动重试只用于幂等方法"。 * * 调用方一旦显式提供 `shouldRetry`,就完全交给它 —— 需要重试 POST 的人显式打开即可, * `retry` 这个预算项本身不变。 */ const shouldRetry = config.shouldRetry ? await config.shouldRetry(toError(error), nextAttempt) : isRetryable(error) && isIdempotentMethod(config.method) if (!shouldRetry) throw toError(error) attempt = nextAttempt const retryContext = { ...getRuntimeDebugContext(), ...config.debugContext } emitHTTPDebug({ id: debugId ?? 0, phase: 'retry', status: 'retrying', url: config.url, method: config.method, headers: debugHeaders(config.headers), requestBody: debugValue(config.body), startedAt: Date.now(), attempt, retries: attempt, error: { name: toError(error).name, message: toError(error).message }, context: Object.keys(retryContext).length > 0 ? retryContext : undefined }) /* * 退避取两者**较大值**: * - 配置的 `retryDelay`(默认 0,即"不额外等") * - 服务端 `Retry-After`(有就必须认,见 serverRetryDelay 的说明) * * 取较大值而不是"有就用":调用方显式配了更长退避时不该被服务端缩短。 */ const serverHint = serverRetryDelay(toError(error)) const configured = resolveRetryDelay(config.retryDelay ?? 0, attempt, toError(error)) const delay = Math.max(configured, serverHint ?? 0) if (delay > 0) await wait(delay) } } } finally { controller.signal.removeEventListener('abort', onAbort) if (timeoutId !== undefined) clearTimeout(timeoutId) inputSignal?.removeEventListener('abort', abortFromInput) } } } function debugHeaders(headers: HTTPHeaders): HTTPHeaders { const safe: HTTPHeaders = {} for (const [key, value] of Object.entries(headers)) { if (/authorization|cookie|token|password|secret|api[-_]?key/i.test(key)) continue safe[key] = value } return safe } function debugValue(value: unknown, depth = 0): unknown { if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value if (value === undefined) return undefined if (depth >= 2) return '[MaxDepth]' if (typeof value === 'bigint') return `${value}n` if (typeof value === 'function') return `[Function ${value.name || 'anonymous'}]` if (typeof value !== 'object') return String(value) if (Array.isArray(value)) return value.slice(0, 20).map(item => debugValue(item, depth + 1)) try { return Object.fromEntries(Object.entries(value).slice(0, 30).map(([key, item]) => [key, debugValue(item, depth + 1)])) } catch { return '[Uninspectable]' } } export function httpPlugin(options: HTTPPluginOptions = {}): VobsPlugin { return { name: '@vobs/http', version: '0.1.0', install(context) { const client = options.client ?? createHTTPClient(options) context.provide(HTTP_KEY, client) } } } function createInterceptors(): InterceptorManager & { handlers(): Array<{ onFulfilled: (value: unknown) => unknown; onRejected: (error: unknown) => unknown }> } { type Handler = { onFulfilled: (value: unknown) => unknown onRejected: (error: unknown) => unknown } const entries: Array = [] return { use( onFulfilled?: (value: T) => unknown | PromiseLike, onRejected?: (error: unknown) => unknown | PromiseLike ) { entries.push({ onFulfilled: (value: unknown) => onFulfilled ? onFulfilled(value as T) : value, onRejected: (error: unknown) => onRejected ? onRejected(error) : Promise.reject(error) }) return entries.length - 1 }, eject(id) { if (id >= 0 && id < entries.length) entries[id] = null }, clear() { entries.fill(null) }, handlers() { return entries.filter((entry): entry is Handler => entry !== null) } } } function normalizeClientOptions(options: HTTPClientOptions): Required> & Pick { return { baseURL: options.baseURL ?? '', headers: options.headers ?? {}, timeout: validateTimeout(options.timeout ?? 0), retry: validateRetry(options.retry ?? 0), retryDelay: options.retryDelay ?? 0, shouldRetry: options.shouldRetry } } export function createConcurrencyLimiter(limit: number): ConcurrencyLimiter { if (limit !== Infinity && (!Number.isFinite(limit) || limit < 1 || !Number.isInteger(limit))) { throw new Error('HTTP: concurrency 必须是大于等于 1 的整数') } let active = 0 const queue: Array<() => void> = [] return (task: () => T | PromiseLike): Promise => new Promise((resolve, reject) => { const run = (): void => { active++ Promise.resolve().then(task).then(resolve, reject).finally(() => { active-- queue.shift()?.() }) } if (active < limit) run() else queue.push(run) }) } function getDedupeKey(config: RequestConfig, defaultEnabled: boolean): string | undefined { if (config.dedupe === false) return undefined if (config.dedupeKey) return config.dedupeKey if (!(config.dedupe ?? defaultEnabled)) return undefined if (config.method !== 'GET' && config.method !== 'HEAD') return undefined /* * 键里必须带上"是谁在问"的凭据。 * * 原来只有 `METHOD url`:两个并发 GET 只要 URL 相同就合并,**哪怕 Authorization 不同** —— * B 的请求根本不会发出,直接拿到 A 的响应(拿错数据,属于安全问题)。 * 只纳入凭据类头,其余头不参与,去重的价值不受影响。 * * 还必须带上**改变响应形态**的选项(`responseShapeFingerprint`): * 同一个 URL 并发要 `responseType:'json'` 与 `'blob'` 时,后者会拿到前者解析好的对象 * (类型与内容都不对,且完全无声 —— 去重是"共享同一个响应",不是"共享同一个请求")。 */ return `${config.method} ${config.url} ${credentialFingerprint(config.headers)} ${responseShapeFingerprint(config)}` } /** * 参与去重键的"响应形态"选项。 * * 这些选项不改变请求发往哪里,但**改变返回给调用方的东西**,所以声明的形态不同就不能合并: * - `responseType`:json / text / blob / arrayBuffer / response —— 差异最大 * - `credentials`:include 与 omit 会拿到不同内容(登录态与未登录态的响应可能完全不同) * - `cache`:default 与 no-store 可能拿到不同的新鲜度 * * **刻意不纳入**:`onDownloadProgress` / `onUploadProgress` / `signal` / `timeout` / `debugContext` * / `retry` 等,它们不影响响应内容,只影响"怎么等"。 * * ⚠️ 但要知道 `onDownloadProgress` 的这个取舍有代价:两个调用方若声明了**不同的**进度回调 * 却共享同一个 key,只有先发出那一个的进度回调会被调用(后者静默收不到进度)。 * 这一点写成注释而不是塞进 key —— 把回调塞进 key 会让"每个新回调都产生新请求", * 去重就彻底失效了。需要独立进度回调的调用方请用 `dedupe: false` 或自定义 `dedupeKey`。 */ function responseShapeFingerprint(config: RequestConfig): string { return [ `rt=${config.responseType ?? 'json'}`, `cr=${config.credentials ?? 'same-origin'}`, `ca=${config.cache ?? 'default'}` ].join(' ') } /** 参与去重键的凭据头(小写)。 */ const DEDUPE_CREDENTIAL_HEADERS = ['authorization', 'cookie', 'proxy-authorization'] as const function credentialFingerprint(headers: RequestConfig['headers']): string { if (!headers) return '' const parts: string[] = [] // for...in 而不是 Object.keys:headers 可能带原型(table 那轮踩过) for (const key in headers) { const lower = key.toLowerCase() if (!(DEDUPE_CREDENTIAL_HEADERS as readonly string[]).includes(lower)) continue const value = (headers as Record)[key] parts.push(`${lower}=${typeof value === 'string' ? value : JSON.stringify(value ?? null)}`) } return parts.sort().join('&') } function normalizeRequest(input: RequestOptions, defaults: ReturnType): RequestConfig { if (!input.url) throw new Error('HTTP: url 不能为空') const method = (input.method ?? 'GET').toUpperCase() as HTTPMethod const timeout = validateTimeout(input.timeout ?? defaults.timeout) const retry = validateRetry(input.retry ?? defaults.retry) const retryDelay = input.retryDelay ?? defaults.retryDelay const signal = input.signal ?? input.state if (input.signal && input.state && input.signal !== input.state) { throw new Error('HTTP: signal 和 state 不能同时指向不同的 AbortSignal') } return { ...input, url: appendParams(resolveURL(input.url, input.baseURL ?? defaults.baseURL), input.params), method, headers: toHeaders(defaults.headers, input.headers), timeout, retry, retryDelay, shouldRetry: input.shouldRetry ?? defaults.shouldRetry, signal } } async function fetchAdapter(config: RequestConfig): Promise { if (typeof fetch !== 'function') throw new Error('HTTP: 当前环境没有可用的 fetch') const headers = new Headers(config.headers) const body = encodeBody(config.body, headers, config.method) return fetch(config.url, { method: config.method, headers, body, signal: config.signal, cache: config.cache, credentials: config.credentials, mode: config.mode }) } async function parseResponse(response: Response, config: RequestConfig): Promise> { const data = config.responseType === 'response' ? response : await parseBody(response, config.responseType, hasHandledProgress(response) ? undefined : config.onDownloadProgress) return { data, status: response.status, statusText: response.statusText, headers: response.headers, config, raw: response } } const builtInAxiosRequest: AxiosRequest = async (config: AxiosRequestConfigLike) => { const responseType = config.responseType === 'response' ? 'arraybuffer' : toAxiosResponseType(config.responseType) const response = await axios.request({ url: config.url, method: config.method, headers: config.headers, data: config.method === 'GET' || config.method === 'HEAD' ? undefined : config.body, signal: config.signal, timeout: config.timeout, responseType, withCredentials: toAxiosCredentials(config.credentials), validateStatus: () => true, adapter: 'fetch', fetchOptions: compactFetchOptions(config), onUploadProgress: event => config.onUploadProgress?.(toAxiosProgress(event.loaded, event.total)), onDownloadProgress: event => config.onDownloadProgress?.(toAxiosProgress(event.loaded, event.total)) }) const raw = config.responseType === 'response' ? toRawResponse(response.data, response.status, response.statusText, response.headers as unknown as HeadersInit) : null return { data: (raw ?? response.data) as T, status: response.status, statusText: response.statusText, headers: (typeof response.headers?.toJSON === 'function' ? response.headers.toJSON() : response.headers) as HeadersInit, raw } } /** 可以安全地自动重试的方法:重复执行与执行一次等价。 */ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']) function isIdempotentMethod(method: string | undefined): boolean { return IDEMPOTENT_METHODS.has((method ?? 'GET').toUpperCase()) } function toAxiosCredentials(credentials?: RequestCredentials): boolean | undefined { if (credentials === 'include') return true if (credentials === 'omit') return false return undefined } function toRawResponse(data: unknown, status: number, statusText: string | undefined, headers: HeadersInit | undefined): Response { if (typeof Response === 'undefined') throw new Error('HTTP: 当前环境没有可用的 Response') const body = status === 204 || status === 205 ? null : data as BodyInit | null | undefined return new Response(body, { status, statusText: statusText ?? '', headers: new Headers(headers) }) } function toAxiosResponseType(responseType?: HTTPResponseType): 'arraybuffer' | 'blob' | 'json' | 'text' | undefined { if (responseType === 'arrayBuffer') return 'arraybuffer' if (responseType === 'response') return undefined return responseType } function compactFetchOptions(config: RequestConfig): Record | undefined { const options = { cache: config.cache, credentials: config.credentials, mode: config.mode } const entries = Object.entries(options).filter(([, value]) => value !== undefined) return entries.length === 0 ? undefined : Object.fromEntries(entries) } function toAxiosProgress(loaded: number, total: number | undefined): HTTPProgress { return { loaded, total, percent: total && total > 0 ? loaded / total * 100 : undefined } } async function parseBody( response: Response, responseType?: HTTPResponseType, onDownloadProgress?: HTTPProgressHandler ): Promise { if (response.status === 204 || response.status === 205) return null const bytes = onDownloadProgress && response.body ? await readResponseBytes(response, onDownloadProgress) : undefined if (responseType === 'blob') return bytes ? new Blob([bytes as unknown as BlobPart]) : response.blob() if (responseType === 'arrayBuffer') return bytes ? bytes.buffer : response.arrayBuffer() const text = bytes ? new TextDecoder().decode(bytes) : await response.text() if (responseType === 'text') return text if (!text) return null if (responseType === 'json' || response.headers.get('content-type')?.includes('json')) { try { return JSON.parse(text) } catch { throw new Error('HTTP: 响应不是有效 JSON') } } return text } async function readResponseBytes(response: Response, onProgress: HTTPProgressHandler): Promise { const reader = response.body!.getReader() const chunks: Uint8Array[] = [] const totalHeader = response.headers.get('content-length') const parsedTotal = totalHeader ? Number(totalHeader) : NaN const total = Number.isFinite(parsedTotal) && parsedTotal >= 0 ? parsedTotal : undefined let loaded = 0 while (true) { const next = await reader.read() if (next.done) break chunks.push(next.value) loaded += next.value.byteLength onProgress(toProgress(loaded, total)) } const result = new Uint8Array(loaded) let offset = 0 for (const chunk of chunks) { result.set(chunk, offset) offset += chunk.byteLength } return result } function isHTTPResponse(value: unknown): value is HTTPResponse { return typeof value === 'object' && value !== null && 'data' in value && 'status' in value && 'config' in value } function isResponse(value: unknown): value is Response { return typeof Response !== 'undefined' && value instanceof Response } function hasHandledProgress(response: Response): boolean { return Boolean((response as Response & { [PROGRESS_HANDLED]?: boolean })[PROGRESS_HANDLED]) } function encodeBody(body: unknown, headers: Headers, method: HTTPMethod): BodyInit | undefined { if (body === undefined || body === null || method === 'GET' || method === 'HEAD') return undefined if (typeof body === 'string' || body instanceof Blob || body instanceof FormData || body instanceof ArrayBuffer || body instanceof URLSearchParams) return body as BodyInit if (!headers.has('content-type')) headers.set('content-type', 'application/json') return JSON.stringify(body) } function toHeaders(...inputs: Array): HTTPHeaders { const headers = new Headers() for (const input of inputs) { if (!input) continue new Headers(input).forEach((value, key) => headers.set(key, value)) } const result: HTTPHeaders = {} headers.forEach((value, key) => { result[key] = value }) return result } function resolveURL(url: string, baseURL: string): string { if (!baseURL || /^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith('//')) return url if (!baseURL) return url return `${baseURL.replace(/\/$/, '')}/${url.replace(/^\//, '')}` } function appendParams(url: string, params?: Record | URLSearchParams): string { if (!params) return url const query = params instanceof URLSearchParams ? params : toSearchParams(params) const serialized = query.toString() if (!serialized) return url return `${url}${url.includes('?') ? '&' : '?'}${serialized}` } function toSearchParams(params: Record): URLSearchParams { const search = new URLSearchParams() for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null) continue if (Array.isArray(value)) { for (const item of value) search.append(key, String(item)) } else if (typeof value === 'object') { search.set(key, JSON.stringify(value)) } else { search.set(key, String(value)) } } return search } function isRetryable(error: unknown): boolean { return error instanceof HTTPError ? error.status === 429 || error.status >= 500 : !isAbortError(error) } function isAbortError(error: unknown): boolean { return Boolean(error) && typeof error === 'object' && ((error as { name?: unknown }).name === 'AbortError' || (error as { code?: unknown }).code === 'ERR_CANCELED') } function resolveRetryDelay(retryDelay: RetryDelay, attempt: number, error: Error): number { const delay = typeof retryDelay === 'function' ? retryDelay(attempt, error) : retryDelay if (!Number.isFinite(delay) || delay < 0) throw new Error('HTTP: retryDelay 必须是大于等于 0 的有限数字') return delay } /** * 服务端在响应里给的**重试提示**(`Retry-After`),没给就返回 undefined。 * * 为什么必须认它:`429`/`503` 上的 `Retry-After` 是服务端在明确说 * "**别现在来**"。客户端零退避连环重试等于把它挡的那部分流量原样打回去 —— 是**放大器**, * 而且违反协议。实测(审计探针):服务端回 `429 + retry-after: 5`,客户端在 4ms 内 * 把 3 次请求全打完(间隔 `[2,0]` ms)。全仓此前 grep `retry-after` 零命中。 * * 支持两种合法格式(RFC 9110 §10.2.3): * - 秒数:`Retry-After: 5` * - HTTP 日期:`Retry-After: Wed, 21 Oct 2015 07:28:00 GMT` * * 只接受**非负有限值**;非法值当作没给(不因为服务端写错就阻断重试)。 * 上限 60 秒:避免一个荒谬的 `Retry-After` 把调用方挂死。 */ const MAX_SERVER_RETRY_DELAY = 60_000 function serverRetryDelay(error: Error): number | undefined { if (!(error instanceof HTTPError)) return undefined const raw = error.response?.headers?.get('retry-after') if (raw === null || raw === undefined) return undefined const value = raw.trim() if (value === '') return undefined let ms: number | undefined if (/^\d+$/u.test(value)) { ms = Number(value) * 1000 } else { const when = Date.parse(value) if (!Number.isNaN(when)) ms = when - Date.now() } if (ms === undefined || !Number.isFinite(ms) || ms < 0) return undefined return Math.min(ms, MAX_SERVER_RETRY_DELAY) } function validateRetry(retry: number): number { if (!Number.isInteger(retry) || retry < 0) throw new Error('HTTP: retry 必须是大于等于 0 的整数') return retry } function validateTimeout(timeout: number): number { if (!Number.isFinite(timeout) || timeout < 0) throw new Error('HTTP: timeout 必须是大于等于 0 的有限数字') return timeout } function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } function wait(delay: number): Promise { return new Promise(resolve => setTimeout(resolve, delay)) } function toProgress(loaded: number, total: number | undefined): HTTPProgress { return { loaded, total, percent: total && total > 0 ? loaded / total * 100 : undefined } }