/** * SimApi Core — 纯 TypeScript 核心,无框架依赖 * * 支持所有 JS/TS 环境(Node.js、浏览器、小程序等)。 * * @example * import { SimApiCore } from '@simcu/simapi' * const api = new SimApiCore() * api.setEndpoints({ default: 'https://api.example.com' }) * const res = await api.query('/users/list', { page: 1 }) */ import { type SimApiVersions, type SimApiAuthConfig, type SimApiApiConfig, type SimApiOptions, type SimApiBaseResponse, } from './types' export type { SimApiVersions, SimApiAuthConfig, SimApiApiConfig, SimApiOptions, SimApiBaseResponse, } from './types' declare const SimApiVersion: string; declare const AppVersion: string; // ── Helper: Fetch with Timeout ──────────────────────────────────────── function fetchWithTimeout( url: string, options: RequestInit, timeout: number = 10000 ): Promise { return Promise.race([ fetch(url, options), new Promise((_, reject) => setTimeout(() => reject(new Error(`Request timeout after ${timeout}ms`)), timeout) ), ]) } // ── Helper: Fetch POST with JSON body ──────────────────────────────────── async function fetchPost( url: string, body: any, headers: Record, timeout: number ): Promise> { const options: RequestInit = { method: 'POST', headers: headers as HeadersInit, body: body instanceof FormData ? body : JSON.stringify(body), credentials: 'omit', // 从不发送 Cookie } const response = await fetchWithTimeout(url, options, timeout) if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw { status: response.status, statusText: response.statusText, data: errorData, message: `HTTP ${response.status}: ${response.statusText}`, } } return response.json() } // ── SimApiCore ──────────────────────────────────────── export class SimApiCore { private debug: boolean = true private auth: SimApiAuthConfig = { token_name: 'simapi-auth-token', check_url: '/user/info', logout_url: '/auth/logout', login_url: '/auth/login', } private webConfig: Map> = new Map(); private api: SimApiApiConfig = { endpoints: {default: ''}, defaultEndpoint: 'default', businessCallback: { 401: () => this.removeToken(), common: () => { }, }, responseCallback: { success: (response: any) => response, error: (_err: any) => { }, }, timeout: 10000, } constructor(options?: SimApiOptions) { if (options) { this.configure(options) } } /** * 从 JSON 文件加载配置 * * 部署时替换 config.json 即可切换环境,无需重新构建。 * * @param file - 配置文件路径,默认 'config.json' * @example * await api.loadFromFile() * await api.loadFromFile('/env/prod.json') */ async loadFromFile(file: string = 'config.json'): Promise { try { const resp = await fetch(file) if (!resp.ok) { this.logDebug(`配置文件 ${file} 加载失败: HTTP ${resp.status}`) return } const config = await resp.json() if (config.debug !== undefined) { this.debug = config.debug } if (config.endpoints) { this.api.endpoints = {...this.api.endpoints, ...config.endpoints} } if (config.defaultEndpoint) { this.api.defaultEndpoint = config.defaultEndpoint } } catch (err: any) { this.logDebug(`配置文件 ${file} 加载失败: ${err.message}`) } } configure(options: SimApiOptions): void { if (options.debug !== undefined) { this.debug = options.debug } if (options.auth) { this.auth = {...this.auth, ...options.auth} } if (options.api) { this.api = { ...this.api, ...options.api, endpoints: {...this.api.endpoints, ...(options.api.endpoints ?? {})}, businessCallback: {...this.api.businessCallback, ...(options.api.businessCallback ?? {})}, responseCallback: {...this.api.responseCallback, ...(options.api.responseCallback ?? {})}, } } } get isDebug() { return this.debug } setDebug(debug: boolean) { this.debug = debug; } setEndpoints(endpoints: { [name: string]: string }): void { this.api.endpoints = {...this.api.endpoints, ...endpoints} } getEndpoint(name?: string): string { return this.api.endpoints[name ?? this.api.defaultEndpoint] ?? '' } setBusinessCallback(code: number | string, callback: (data: any) => void): void { this.api.businessCallback[code] = callback } getToken(): string { const name = this.auth.token_name const match = document.cookie.match(new RegExp(`(?:^|;)\\s?${name}=([^;]+)`)) return match ? match[1] : '' } setToken(token: string): void { const name = this.auth.token_name document.cookie = `${name}=${token}; path=/; max-age=315360000; secure; samesite=none` } removeToken(): void { const name = this.auth.token_name document.cookie = `${name}=; path=/; max-age=0; secure; samesite=none` } genS4(): string { return (((1 + Math.random()) * 0x10000 * Date.parse(new Date().toString())) | 0) .toString(16) .substring(1) } /** * 日志工具(仅在 debug 模式下输出) * * @example * api.logDebug('用户登录', { id: 1, name: 'test' }) * api.logDebug('请求开始', uri, params) */ logDebug(...args: any[]): void { if (!this.debug) return console.log('[DEBUG]', ...args) } async getConfig(reload: boolean = false, endpointName: string = 'default'): Promise> { if (!this.webConfig.has(endpointName) || reload) { const versions: SimApiVersions = { uiApp: typeof AppVersion === 'undefined' ? "0.0.0-develop" : AppVersion, uiSimApi: typeof SimApiVersion === 'undefined' ? "0.0.0-develop" : SimApiVersion, apiApp: '0.0.0', apiSimApi: '0.0.0', apiAppFull: '0.0.0', apiSimApiFull: '0.0.0', }; const resp = await this.query('/config', {}, endpointName) if (resp?.data) { const d = resp.data.Versions; versions.apiApp = d.App?.split('+')[0] ?? '0.0.0'; versions.apiSimApi = d.SimApi?.split('+')[0] ?? '0.0.0'; versions.apiAppFull = d.App ?? '0.0.0'; versions.apiSimApiFull = d.SimApi ?? '0.0.0'; if (this.debug) { console.log(`UI主应用版本: ${versions.uiApp}\nUISimApi版本: ${versions.uiSimApi}\nAPI主应用版本: ${versions.apiApp}\nAPISimApi版本: ${versions.apiSimApi}`) } const conf = JSON.parse(JSON.stringify(resp.data)); conf.Versions = versions; this.webConfig.set(endpointName, conf); } } return this.webConfig.get(endpointName)!; } async query( uri: string, params: any = {}, endpointKey?: string, extraHeaders?: Record, selfHandleError: boolean = false ): Promise> { const headers: Record = {...extraHeaders, ...{}} const queryId = this.genS4() if (!(params instanceof FormData)) { headers['Content-Type'] = 'application/json' } const token = this.getToken() if (token) { headers['Token'] = token } if (this.debug) { headers['Query-Id'] = queryId console.log('[REQUEST*]', queryId, '->', uri, 'AUTH:', this.getToken()) } const url = this.getEndpoint(endpointKey) + uri try { const respData = await fetchPost( url, params, headers, this.api.timeout ?? 10000 ) if (this.debug) { console.log('[RESPONSE]', queryId, '->', respData) } const processedData = this.api.responseCallback.success(respData) as SimApiBaseResponse // 业务回调处理 if (!selfHandleError) { if (this.api.businessCallback.hasOwnProperty(processedData.code)) { this.api.businessCallback[processedData.code](processedData) } else if (this.api.businessCallback['common'] && processedData.code !== 200) { this.api.businessCallback['common'](processedData) } } // code != 200 时抛出业务错误 if (processedData.code !== 200) { throw processedData } return processedData } catch (error: any) { if (this.debug) { console.log('[RESPONSE]', queryId, '->', error) } // 网络/HTTP 错误:包装成标准响应格式抛出 if (!error?.code) { this.api.responseCallback.error(error) throw { code: -1, message: error?.message || '网络错误', data: error, } as SimApiBaseResponse } // 业务错误直接抛出 throw error } } async login(request: Record): Promise> { const result = await this.query(this.auth.login_url, request) if (result?.data) { this.setToken(result.data) } return result } async logout(url?: string | null): Promise { if (url !== null) { this.query(url ?? this.auth.logout_url).catch(() => true) } this.removeToken() return true } async checkLogin(url?: string | null): Promise { return this.query(url ?? this.auth.check_url); } }