140 行
4.4 KiB
TypeScript
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
|
|
}
|