XuqmGroup-RNSDK/packages/common/src/http.ts

140 行
4.4 KiB
TypeScript

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<string | null> {
return AsyncStorage.getItem(TOKEN_KEY)
}
export async function _saveAccessToken(token: string): Promise<void> {
return AsyncStorage.setItem(TOKEN_KEY, token)
}
export async function _clearAccessToken(): Promise<void> {
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<T>(
path: string,
options: {
method?: string
body?: unknown
params?: Record<string, string>
skipAuth?: boolean
/** 调用方取消信号;版本检查等可在页面退出时立即停止。 */
signal?: AbortSignal
/** 默认 30 秒,必须为正数;防止网络异常永久阻塞扩展入口。 */
timeoutMs?: number
} = {},
): Promise<T> {
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<string, string> = {
'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
}