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

365 行
12 KiB
TypeScript

import AsyncStorage from '@react-native-async-storage/async-storage'
import { z } from 'zod'
import {
_notifyInitialized,
_setInitializationSnapshot,
_setPrivacyConsent,
_setUserInfo,
getInitializationSnapshot,
getUserId as getCommonUserId,
getUserInfo as getCommonUserInfo,
initConfigFromRemote,
isInitialized,
type InternalInitOptions,
type XuqmLoginOptions,
type XuqmUserInfo,
} from './config'
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
import { sha256Hex } from './crypto'
import { XuqmError } from './errors'
import { _clearAccessToken, _saveAccessToken, configureHttp } from './http'
import { retryWithBackoff } from './retry'
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from './secureCache'
import type { DecryptedConfig } from './crypto'
let _initPromise: Promise<void> | null = null
let _resolvedConfigInitPromise: Promise<void> | null = null
let _resolvedConfigRequest: {
config: DecryptedConfig
options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' }
} | null = null
let _initializationKey: string | null = null
let _sessionTransition: Promise<void> = Promise.resolve()
let _activeSessionKey: string | null = null
const REMOTE_CONFIG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000
const REMOTE_CONFIG_CACHE_PREFIX = '@xuqm_common:remote_config:v1:'
const flatRemoteConfigSchema = z.object({
apiUrl: z.string().optional(),
imApiUrl: z.string().optional(),
imWsUrl: z.string().optional(),
fileServiceUrl: z.string().optional(),
imEnabled: z.boolean().optional(),
pushEnabled: z.boolean().optional(),
bugCollectApiUrl: z.string().optional(),
bugCollectEnabled: z.boolean().optional(),
updateRequiresLogin: z.boolean().optional(),
})
const remoteConfigResponseSchema = z
.object({
apiUrl: z.string().optional(),
imApiUrl: z.string().optional(),
imWsUrl: z.string().optional(),
fileServiceUrl: z.string().optional(),
imEnabled: z.boolean().optional(),
pushEnabled: z.boolean().optional(),
bugCollectApiUrl: z.string().optional(),
bugCollectEnabled: z.boolean().optional(),
updateRequiresLogin: z.boolean().optional(),
allowAnonymousUpdateCheck: z.boolean().optional(),
features: z
.object({
im: z.boolean().optional(),
push: z.boolean().optional(),
bugCollect: z.boolean().optional(),
})
.optional(),
})
.loose()
interface CachedRemoteConfig {
cachedAt: number
remote: {
apiUrl?: string
imApiUrl?: string
imWsUrl?: string
fileServiceUrl?: string
imEnabled?: boolean
pushEnabled?: boolean
bugCollectApiUrl?: string
bugCollectEnabled?: boolean
updateRequiresLogin?: boolean
}
}
function runtimePlatform(): 'ANDROID' | 'IOS' {
try {
// 延迟加载避免 common 的纯 Node 工具与测试被 React Native 的 Flow 源码绑定。
// Metro 仍能静态发现该依赖,移动端运行时以 React Native Platform 为唯一事实。
const reactNative = require('react-native') as {
Platform?: { OS?: string }
}
return reactNative.Platform?.OS === 'ios' ? 'IOS' : 'ANDROID'
} catch {
return 'ANDROID'
}
}
function initializationKey(options: InternalInitOptions): string {
return `${options.appKey.trim()}\n${(options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')}\n${options.packageName ?? ''}`
}
function storageKey(options: InternalInitOptions): string {
return `${REMOTE_CONFIG_CACHE_PREFIX}${sha256Hex(initializationKey(options))}`
}
async function readCachedRemoteConfig(
options: InternalInitOptions,
signingKey?: string,
): Promise<CachedRemoteConfig | null> {
if (!signingKey) return null
try {
const encrypted = await AsyncStorage.getItem(storageKey(options))
if (!encrypted) return null
const raw = openLocalCache(encrypted, signingKey, initializationKey(options))
const cached = JSON.parse(raw) as CachedRemoteConfig
const parsedRemote = flatRemoteConfigSchema.safeParse(cached?.remote)
if (
!cached ||
!isCacheRecordFresh(cached.cachedAt, Date.now(), REMOTE_CONFIG_CACHE_TTL_MS) ||
!parsedRemote.success
) {
await AsyncStorage.removeItem(storageKey(options))
return null
}
return { cachedAt: cached.cachedAt, remote: parsedRemote.data }
} catch {
await AsyncStorage.removeItem(storageKey(options)).catch(() => undefined)
return null
}
}
async function writeCachedRemoteConfig(
options: InternalInitOptions,
remote: CachedRemoteConfig['remote'],
signingKey?: string,
): Promise<void> {
if (!signingKey) return
try {
const encrypted = sealLocalCache(
JSON.stringify({ cachedAt: Date.now(), remote } satisfies CachedRemoteConfig),
signingKey,
initializationKey(options),
)
await AsyncStorage.setItem(storageKey(options), encrypted)
} catch {
// 安全随机数或沙箱存储不可用时跳过缓存,不能降级为明文,也不能阻断初始化。
}
}
function startInitialization(options: InternalInitOptions, signingKey?: string): Promise<void> {
const nextKey = initializationKey(options)
if (isInitialized()) {
if (_initializationKey !== nextKey) {
return Promise.reject(new Error('[XuqmSDK] Already initialized with a different config.'))
}
return Promise.resolve()
}
if (_initPromise) {
if (_initializationKey !== nextKey) {
return Promise.reject(
new Error('[XuqmSDK] Initialization is already running with a different config.'),
)
}
return _initPromise
}
_initializationKey = nextKey
_initPromise = (async () => {
_setInitializationSnapshot({ state: 'initializing' })
const platformUrl = (options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')
const configParams = new URLSearchParams({
appKey: options.appKey,
platform: runtimePlatform(),
})
if (options.packageName) configParams.set('packageName', options.packageName)
const configUrl = `${platformUrl}/api/sdk/config?${configParams.toString()}`
let remote: CachedRemoteConfig['remote']
let source: 'remote' | 'cache' = 'remote'
try {
const res = await retryWithBackoff(
async () => {
const response = await fetch(configUrl)
if (response.ok) return response
if (response.status >= 500) {
throw new Error(`[XuqmSDK] Platform config request failed: ${response.status}`)
}
throw new XuqmError(
'XUQM_NOT_READY',
`[XuqmSDK] Platform config request failed: ${response.status}`,
)
},
{
maxAttempts: 3,
shouldRetry: error => !(error instanceof XuqmError),
},
)
const json = (await res.json()) as Record<string, unknown>
const raw = remoteConfigResponseSchema.parse(json.data ?? json)
const features = raw.features ?? {}
remote = {
apiUrl: raw.apiUrl,
imApiUrl: raw.imApiUrl,
imWsUrl: raw.imWsUrl,
fileServiceUrl: raw.fileServiceUrl,
imEnabled: features.im ?? raw.imEnabled,
pushEnabled: features.push ?? raw.pushEnabled,
bugCollectApiUrl: raw.bugCollectApiUrl,
bugCollectEnabled: features.bugCollect ?? raw.bugCollectEnabled,
updateRequiresLogin:
raw.updateRequiresLogin ??
(raw.allowAnonymousUpdateCheck === undefined
? undefined
: !raw.allowAnonymousUpdateCheck),
}
await writeCachedRemoteConfig(options, remote, signingKey)
} catch (error) {
const cached = await readCachedRemoteConfig(options, signingKey)
if (!cached) {
throw new XuqmError(
'XUQM_NOT_READY',
'[XuqmSDK] Tenant configuration is unavailable and no valid cache exists.',
error,
)
}
remote = cached.remote
source = 'cache'
}
initConfigFromRemote({ ...options, platformUrl, configSource: source }, remote, signingKey)
// Xuqm 平台接口始终回到租户平台。remote.apiUrl 是业务扩展服务地址,
// 二者职责不同;混用会把版本检查错误地发送到 App 自身的业务服务。
configureHttp({
baseUrl: platformUrl,
debug: options.debug,
})
await _notifyInitialized()
_setInitializationSnapshot({
state: source === 'remote' ? 'ready' : 'degraded',
source,
})
})().catch(error => {
_initPromise = null
_initializationKey = null
const structured =
error instanceof XuqmError
? error
: new XuqmError('XUQM_NOT_READY', '[XuqmSDK] Initialization failed.', error)
_setInitializationSnapshot({
state: 'failed',
error: { code: structured.code, message: structured.message },
})
throw structured
})
return _initPromise
}
function enqueueSessionTransition(action: () => Promise<void>): Promise<void> {
const next = _sessionTransition.catch(() => undefined).then(action)
_sessionTransition = next
return next
}
export const XuqmSDK = {
/**
* SDK XuqmSDK
*/
async awaitInitialization(): Promise<void> {
await awaitInitialization()
},
getInitializationState() {
return getInitializationSnapshot()
},
/**
* 宿SDK
*/
async setPrivacyConsent(consented: boolean): Promise<void> {
await _setPrivacyConsent(consented)
},
getUserId(): string | null {
return getCommonUserId()
},
/**
* SDK
* null SDK
*/
async login(options: XuqmLoginOptions): Promise<void> {
const userId = options.userId.trim()
if (!userId) throw new Error('[XuqmSDK] userId is required.')
const accessToken = options.accessToken?.trim() || undefined
const normalized = { ...options, accessToken, userId }
const nextSessionKey = JSON.stringify(normalized)
await enqueueSessionTransition(async () => {
await awaitInitialization()
if (_activeSessionKey === nextSessionKey && getCommonUserInfo()) return
if (accessToken) await _saveAccessToken(accessToken)
else await _clearAccessToken()
const { accessToken: _accessToken, ...userInfo } = normalized
await _setUserInfo(userInfo)
_activeSessionKey = nextSessionKey
})
},
async logout(): Promise<void> {
await enqueueSessionTransition(async () => {
await _clearAccessToken()
if (getCommonUserInfo()) await _setUserInfo(null)
_activeSessionKey = null
})
},
getUserInfo(): XuqmUserInfo | null {
return getCommonUserInfo()
},
}
export async function awaitInitialization(): Promise<void> {
if (isInitialized()) return
const pendingInitialization =
_resolvedConfigInitPromise ??
_initPromise ??
(_resolvedConfigRequest
? _initializeFromResolvedConfig(_resolvedConfigRequest.config, _resolvedConfigRequest.options)
: null)
if (!pendingInitialization) {
throw new XuqmError(
'XUQM_NOT_READY',
'[XuqmSDK] Automatic initialization did not start. Place exactly one .xuqmconfig file in src/assets/config and wrap Metro with withXuqmConfig().',
)
}
await pendingInitialization
}
/** Metro 自动初始化专用入口;配置已在可信构建进程中完成解密和格式校验。 */
export function _initializeFromResolvedConfig(
config: DecryptedConfig,
options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' },
): Promise<void> {
_resolvedConfigRequest = { config, options }
if (_resolvedConfigInitPromise) return _resolvedConfigInitPromise
_resolvedConfigInitPromise = (async () => {
const platformUrl = config.serverUrl
await startInitialization(
{
appKey: config.appKey,
platformUrl,
debug: options?.debug,
bugCollectMode: options?.bugCollectMode,
packageName:
runtimePlatform() === 'IOS'
? (config.iosBundleId ?? config.packageName)
: config.packageName,
},
config.signingKey,
)
})().catch(error => {
_resolvedConfigInitPromise = null
throw error
})
return _resolvedConfigInitPromise
}