import { $str } from '@cat-kit/core' import type { HttpEngine } from './engine/engine' import { FetchEngine } from './engine/fetch' import { XHREngine } from './engine/xhr' import type { ClientConfig, RequestConfig, HTTPResponse, AliasRequestConfig, RequestContext, HTTPClientPlugin, IHTTPClient } from './types' import { HTTPError } from './types' /** * 检查目标 URL 是否与当前页面同域 * - 仅在浏览器环境下有效,Node.js 环境始终返回 false */ function isSameOrigin(url: string): boolean { if (typeof location === 'undefined') return false try { const parsed = new URL(url, location.href) return parsed.protocol === location.protocol && parsed.host === location.host } catch { return false } } /** * 读取指定名称的 Cookie 值 * - 仅在浏览器环境下有效 * - Escaped 值会自动 decodeURIComponent 解码 */ function readCookie(name: string): string | undefined { if (typeof document === 'undefined') return undefined const match = document.cookie.match( new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)') ) return match ? decodeURIComponent(match[1]!) : undefined } const MERGE_SCALAR_KEYS: (keyof RequestConfig)[] = [ 'method', 'body', 'timeout', 'credentials', 'responseType', 'signal', 'onUploadProgress', 'onDownloadProgress', 'xsrfCookieName', 'xsrfHeaderName' ] /** * 合并请求配置:headers / query 做对象级合并;标量类字段仅在 patch 显式传入且非 undefined 时覆盖。 * `undefined` 不用于清空已有配置。 */ export function mergeRequestConfig(base: RequestConfig, patch: RequestConfig): RequestConfig { const headers = { ...base.headers } if (patch.headers !== undefined) { Object.assign(headers, patch.headers) } const out: RequestConfig = { ...base, headers } if (patch.query !== undefined) { out.query = { ...base.query, ...patch.query } } for (const key of MERGE_SCALAR_KEYS) { if (Object.prototype.hasOwnProperty.call(patch, key) && patch[key] !== undefined) { ;(out as Record)[key as string] = patch[key] } } const patchRec = patch as Record const outRec = out as Record if (Object.prototype.hasOwnProperty.call(patchRec, '_retryAttempt')) { outRec['_retryAttempt'] = patchRec['_retryAttempt'] } return out } /** * 检查某个值是否为有效的 HTTPResponse 结构(用于 `onError` 插件的恢复判断) */ function isRecoveredHTTPResponse(value: unknown): value is HTTPResponse { if (value === null || typeof value !== 'object') { return false } const o = value as Record return ( typeof o.code === 'number' && 'body' in o && typeof o.headers === 'object' && o.headers !== null ) } /** * HTTP 请求客户端 * * 提供了一个符合人体工学的,跨端(node 和浏览器)的 HTTP 请求客户端。 * 支持插件系统,可以灵活地组合和增强请求客户端。 * * @example * ```ts * import { HTTPClient } from '@cat-kit/http' * * const http = new HTTPClient('/api', { * origin: 'http://localhost:8080', * timeout: 30 * 1000 * }) * * // 发起请求 * http.request('/user', { method: 'get' }).then(res => { * // ...do some things * }) * * // 请求别名 * http.get('/user', { query: { name: 'Zhang San' } }).then(res => { * // ...do some things * }) * ``` */ export class HTTPClient implements IHTTPClient { /** 请求前缀 */ private prefix: string /** 客户端配置 */ private config: ClientConfig /** 请求引擎 */ private engine: HttpEngine /** 父 client(仅由 group() 内部赋值;根 client 为 undefined) */ private parent?: HTTPClient /** 当前 client 自身持有的插件列表(不含父链继承) */ private ownPlugins: HTTPClientPlugin[] = [] /** * 创建 HTTP 客户端实例 * @param prefix 请求前缀 * @param config 客户端配置 */ constructor(prefix: string = '', config: ClientConfig = {}) { this.prefix = prefix this.config = config this.engine = config.engine ?? (typeof fetch === 'undefined' ? new XHREngine() : new FetchEngine()) for (const plugin of config.plugins ?? []) { this.registerPluginInternal(plugin) } } /** * 计算当前 client 在运行时生效的插件列表:父链在前、子在后 */ private getEffectivePlugins(): HTTPClientPlugin[] { return [...(this.parent?.getEffectivePlugins() ?? []), ...this.ownPlugins] } getEngine(): HttpEngine { return this.engine } /** * 注册插件(运行时动态装配) * - 插件必须拥有非空字符串 `name`,否则抛 HTTPError({ code: 'PLUGIN' }) * - 插件名在 client 自身及其父链范围内必须唯一,冲突时抛 HTTPError({ code: 'PLUGIN' }) */ registerPlugin(plugin: HTTPClientPlugin): void { if ( plugin == null || typeof plugin !== 'object' || typeof plugin.name !== 'string' || plugin.name === '' ) { throw new HTTPError('插件必须提供非空 name 字段', { code: 'PLUGIN' }) } this.registerPluginInternal(plugin) } /** * 内部注册插件(不做 name 有效性校验,调用方保证已校验通过) * - 校验插件名在整个生效链(父链+自身)中的唯一性,冲突时抛 HTTPError */ private registerPluginInternal(plugin: HTTPClientPlugin): void { const existingNames = new Set(this.getEffectivePlugins().map((p) => p.name)) if (existingNames.has(plugin.name)) { throw new HTTPError(`插件名称冲突: ${plugin.name}`, { code: 'PLUGIN' }) } this.ownPlugins.push(plugin) } /** * 为同域请求自动附加 XSRF Token(通过 Cookie → Header 注入) * - 不同域请求直接跳过 * - Cookie 不存在时跳过 */ private applyXsrfHeader(url: string, config: RequestConfig): RequestConfig { const cookieName = config.xsrfCookieName ?? this.config.xsrfCookieName ?? 'XSRF-TOKEN' const headerName = config.xsrfHeaderName ?? this.config.xsrfHeaderName ?? 'X-XSRF-TOKEN' if (!isSameOrigin(url)) { return config } const token = readCookie(cookieName) if (!token) { return config } const headers = { ...config.headers } headers[headerName] = token return { ...config, headers } } /** * 拼接完整请求 URL:前缀 + origin + query 参数 * - 若 url 已是完整 URL 则跳过拼接,仅追加 query 参数 */ private getRequestUrl(url: string, config: RequestConfig): string { // 如果已经是完整URL,直接返回 if (this.isAbsoluteUrl(url)) { return this.appendQueryParams(url, config) } // 拼接前缀 url = $str.joinUrlPath(this.prefix, url) // 如果配置了 origin,拼接到 URL 前面 if (this.config.origin) { // 移除 origin 末尾的斜杠 const origin = this.config.origin.replace(/\/$/, '') url = origin + url } return this.appendQueryParams(url, config) } /** 判断是否为完整 URL(含协议头或以 // 开头) */ private isAbsoluteUrl(url: string): boolean { return /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(url) || url.startsWith('//') } /** * 将 config.query 序列化并拼接到 URL * - 支持数组值(多 key 追加)、对象值(JSON 序列化)、null/undefined */ private appendQueryParams(url: string, config: RequestConfig): string { // 处理查询参数(对所有请求方法都有效) if (config.query) { const searchParams = new URLSearchParams() const appendValue = (key: string, value: unknown): void => { if (value === undefined) { return } if (value === null) { searchParams.append(key, 'null') return } if (typeof value === 'object') { searchParams.append(key, JSON.stringify(value)) return } searchParams.append(key, String(value)) } Object.entries(config.query).forEach(([key, value]) => { if (Array.isArray(value)) { value.forEach((item) => appendValue(key, item)) return } appendValue(key, value) }) const queryString = searchParams.toString() if (queryString) { url = url.includes('?') ? `${url}&${queryString}` : `${url}?${queryString}` } } return url } /** * 获取请求配置, 合并 HTTPClient 实例配置和当前配置 * @param config 当前请求配置 * @returns 合并后的请求配置 */ private getRequestConfig(config: RequestConfig): RequestConfig { const { timeout, credentials, headers, responseType, signal, onUploadProgress, onDownloadProgress } = this.config return { timeout, credentials, responseType, signal, onUploadProgress, onDownloadProgress, ...config, headers: { ...headers, ...config.headers } } } /** * 依次执行插件 onError 钩子,取首个有效的恢复响应 * - 只有返回 HTTPResponse 时才视为恢复;后续插件仍会执行(用于副作用) */ private async runOnErrorPlugins( error: unknown, context: RequestContext, plugins: HTTPClientPlugin[] ): Promise { if (!plugins.length) { return undefined } let recovered: HTTPResponse | undefined for (const plugin of plugins) { if (!plugin.onError) { continue } const result = await plugin.onError(error, context) if (recovered === undefined && isRecoveredHTTPResponse(result)) { recovered = result } } return recovered } /** * 执行单次请求的核心流程(含插件管道) */ private async _executeRequest( originalUrl: string, originalConfig: RequestConfig, effectivePlugins: HTTPClientPlugin[] = this.getEffectivePlugins() ): Promise> { let finalUrl = this.getRequestUrl(originalUrl, originalConfig) let finalConfig = originalConfig try { if (effectivePlugins.length) { for (const plugin of effectivePlugins) { if (plugin.beforeRequest) { const result = await plugin.beforeRequest({ url: finalUrl, config: finalConfig }) if (result) { if (result.url) { finalUrl = result.url } if (result.config) { finalConfig = mergeRequestConfig(finalConfig, result.config) } } } } } finalConfig = this.applyXsrfHeader(finalUrl, finalConfig) let response = await this.engine.request(finalUrl, finalConfig) if (effectivePlugins.length) { for (const plugin of effectivePlugins) { if (plugin.afterRespond) { const result = await plugin.afterRespond({ response, url: finalUrl, config: finalConfig, originalUrl, originalConfig, client: this }) if (result) { response = result } } } } return response } catch (error) { if (error instanceof HTTPError && error.response !== undefined && effectivePlugins.length) { let response = error.response as HTTPResponse let recoveredViaAfterRespond = false for (const plugin of effectivePlugins) { if (plugin.afterRespond) { const result = await plugin.afterRespond({ response, url: finalUrl, config: finalConfig, originalUrl, originalConfig, client: this }) if (result) { response = result recoveredViaAfterRespond = true } } } if (recoveredViaAfterRespond && response.code >= 200 && response.code < 300) { return response } } const recovered = await this.runOnErrorPlugins( error, { url: finalUrl, config: finalConfig }, effectivePlugins ) if (recovered !== undefined) { return recovered as HTTPResponse } throw error } } /** * 发送 HTTP 请求 * @param url 请求地址 * @param config 请求配置 * @returns Promise */ async request(url: string, config: RequestConfig = {}): Promise> { const mergedConfig = this.getRequestConfig(config) return this._executeRequest(url, mergedConfig) } /** * 发送 GET 请求 * @param url 请求地址 * @param config 请求选项 * @returns Promise */ get(url: string, config: AliasRequestConfig = {}): Promise> { return this.request(url, { ...config, method: 'GET' }) } /** * 发送 POST 请求 * @param url 请求地址 * @param body 请求体 * @param config 请求选项 * @returns Promise */ post( url: string, body?: RequestConfig['body'], config: Omit = {} ): Promise> { return this.request(url, { ...config, method: 'POST', body }) } /** * 发送 PUT 请求 * @param url 请求地址 * @param body 请求体 * @param config 请求选项 * @returns Promise */ put( url: string, body?: RequestConfig['body'], config: Omit = {} ): Promise> { return this.request(url, { ...config, method: 'PUT', body }) } /** * 发送 DELETE 请求 * @param url 请求地址 * @param config 请求选项 * @returns Promise */ delete( url: string, config: Omit = {} ): Promise> { return this.request(url, { ...config, method: 'DELETE' }) } /** * 发送 PATCH 请求 * @param url 请求地址 * @param body 请求体 * @param config 请求选项 * @returns Promise */ patch( url: string, body?: RequestConfig['body'], config: Omit = {} ): Promise> { return this.request(url, { ...config, method: 'PATCH', body }) } /** * 发送 HEAD 请求 * @param url 请求地址 * @param config 请求选项 * @returns Promise */ head(url: string, config: Omit = {}): Promise> { return this.request(url, { ...config, method: 'HEAD' }) } /** * 发送 OPTIONS 请求 * @param url 请求地址 * @param config 请求选项 * @returns Promise */ options( url: string, config: Omit = {} ): Promise> { return this.request(url, { ...config, method: 'OPTIONS' }) } /** * 中止所有请求 */ abort(): void { // 中止当前引擎的所有请求 this.engine.abort() } /** * 创建请求分组 * @param prefix 分组前缀 * @returns HTTPClient 新的客户端实例 * * 插件继承语义: * - 子 client 通过父链继承插件;父后续 `registerPlugin` 会自动反映到子(父影响子) * - 子 `registerPlugin` 仅改动 `child.ownPlugins`(子不影响父) * - 同名校验跨父子层级生效 * * 注意:父子共享同一 `engine` 实例,`abort()` 会中止父或子任意一方触发该引擎的所有在途请求。 * * @example * ```ts * const http = new HTTPClient() * const userGroup = http.group('/user') * * // 等同于 http.get('/user/profile') * userGroup.get('/profile') * * // 中止分组中的所有请求 * userGroup.abort() * ``` */ group(prefix: string): HTTPClient { const child = new HTTPClient($str.joinUrlPath(this.prefix, prefix), { origin: this.config.origin, timeout: this.config.timeout, credentials: this.config.credentials, headers: this.config.headers, engine: this.engine, responseType: this.config.responseType, signal: this.config.signal, onUploadProgress: this.config.onUploadProgress, onDownloadProgress: this.config.onDownloadProgress, xsrfCookieName: this.config.xsrfCookieName, xsrfHeaderName: this.config.xsrfHeaderName }) child.parent = this return child } }