import AsyncStorage from '@react-native-async-storage/async-storage' import { DEFAULT_TENANT_PLATFORM_URL } from './constants' import { getOptionalConfig, getUserId } from './config' import { hmacSha256Base64Url } from './crypto' import { safeRequestPath } from './api/diagnostics' const TOKEN_KEY = '@xuqm:token' let _baseUrl = DEFAULT_TENANT_PLATFORM_URL let _debugHttp = false export function configureHttp(options: { baseUrl?: string; debug?: boolean } = {}) { if (options.baseUrl) { _baseUrl = options.baseUrl.replace(/\/$/, '') } if (typeof options.debug === 'boolean') { _debugHttp = options.debug } } export async function _getAccessToken(): Promise { return AsyncStorage.getItem(TOKEN_KEY) } export async function _saveAccessToken(token: string): Promise { return AsyncStorage.setItem(TOKEN_KEY, token) } export async function _clearAccessToken(): Promise { return AsyncStorage.removeItem(TOKEN_KEY) } let nonceSequence = 0 function generateRequestNonce(secret: string, appKey: string): string { nonceSequence = (nonceSequence + 1) % Number.MAX_SAFE_INTEGER const seed = `${Date.now()}\n${nonceSequence}\n${appKey}` const value = hmacSha256Base64Url(secret, seed) return `${value.slice(0, 8)}-${value.slice(8, 12)}-4${value.slice(13, 16)}-a${value.slice(17, 20)}-${value.slice(20, 32)}` } export async function apiRequest( path: string, options: { method?: string body?: unknown params?: Record skipAuth?: boolean /** 调用方取消信号;版本检查等可在页面退出时立即停止。 */ signal?: AbortSignal /** 默认 30 秒,必须为正数;防止网络异常永久阻塞扩展入口。 */ timeoutMs?: number } = {}, ): Promise { let url = path.startsWith('http://') || path.startsWith('https://') ? path : `${_baseUrl}${path}` if (options.params) { const qs = new URLSearchParams(options.params).toString() url += (url.includes('?') ? '&' : '?') + qs } const headers: Record = { 'Content-Type': 'application/json', Accept: 'application/json', } if (!options.skipAuth) { const token = await _getAccessToken() if (token) headers['Authorization'] = `Bearer ${token}` } const config = getOptionalConfig() const signingKey = config?.signingKey if (config && signingKey) { const timestamp = Math.floor(Date.now() / 1000).toString() const nonce = generateRequestNonce(signingKey, config.appKey) const userId = getUserId() ?? '' const payload = `${config.appKey}\n${userId}\n${timestamp}\n${nonce}` headers['X-App-Key'] = config.appKey headers['X-Timestamp'] = timestamp headers['X-Nonce'] = nonce headers['X-Signature'] = hmacSha256Base64Url(signingKey, payload) if (userId) headers['X-User-Id'] = userId } if (_debugHttp) { console.debug('[XuqmSDK][HTTP] request', { method: options.method ?? 'GET', path: safeRequestPath(url), hasBody: Boolean(options.body), }) } const timeoutMs = options.timeoutMs ?? 30_000 if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { throw new Error('[XuqmSDK][HTTP] timeoutMs must be a positive number') } const controller = new AbortController() const abortFromCaller = () => controller.abort(options.signal?.reason) if (options.signal?.aborted) abortFromCaller() else options.signal?.addEventListener('abort', abortFromCaller, { once: true }) const timeout = setTimeout(() => { controller.abort(new Error(`[XuqmSDK][HTTP] Request timed out after ${timeoutMs}ms`)) }, timeoutMs) let res: Response try { res = await fetch(url, { method: options.method ?? 'GET', headers, body: options.body ? JSON.stringify(options.body) : undefined, signal: controller.signal, }) } finally { clearTimeout(timeout) options.signal?.removeEventListener('abort', abortFromCaller) } if (!res.ok) { const err = await res.json().catch(() => ({ message: res.statusText })) if (_debugHttp) { console.debug('[XuqmSDK][HTTP] response', { path: safeRequestPath(url), status: res.status, ok: false, }) } throw new Error((err as { message?: string }).message ?? `HTTP ${res.status}`) } const json = await res.json() if (_debugHttp) { console.debug('[XuqmSDK][HTTP] response', { path: safeRequestPath(url), status: res.status, ok: true, }) } return (json.data ?? json) as T }