From 9e11a63144531ff3cf618ce0a0e9153293b7ae3b Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 1 May 2026 21:27:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E6=9B=B4=E6=96=B0=20SDK=20?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3=E5=92=8C=20API=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 expiresAt 和 refreshUserSig 参数支持自动续签 - 修改 PushSDK 初始化方式,自动完成设备注册和厂商初始化 - 调整过期续签策略,从提前 15 分钟改为提前 5 分钟触发 - 重构 RN SDK 文档结构,简化安装和使用方式 - 更新统一登录流程,支持 profile 信息传递 - 添加 IM 数据库自动隔离功能 - 修复 Android 群消息聚合问题 - 补充自动化测试验证和错误处理机制 --- README.md | 276 ++++-------------------------- packages/im/package.json | 5 +- packages/im/src/ImSDK.ts | 26 ++- packages/im/src/db/ImDatabase.ts | 4 +- packages/im/src/runtime.ts | 28 +++ packages/im/src/upload.ts | 2 +- packages/im/tests/runtime.test.ts | 34 ++++ packages/im/tsconfig.test.json | 16 ++ packages/push/src/PushSDK.ts | 51 +++++- src/core/config.ts | 16 -- src/core/http.ts | 49 ------ src/core/sdk.ts | 26 --- src/im/imClient.ts | 209 ---------------------- src/im/imSDK.ts | 136 --------------- src/im/types.ts | 58 ------- src/index.ts | 28 ++- src/push/pushSDK.ts | 28 --- src/sdk.ts | 117 +++++++++++++ src/shims/async-storage.ts | 19 ++ src/shims/watermelondb.ts | 67 ++++++++ src/types/async-storage.d.ts | 4 +- src/types/watermelondb.d.ts | 57 ++---- src/update/updateSDK.ts | 130 -------------- tsconfig.json | 5 + 24 files changed, 424 insertions(+), 967 deletions(-) create mode 100644 packages/im/src/runtime.ts create mode 100644 packages/im/tests/runtime.test.ts create mode 100644 packages/im/tsconfig.test.json delete mode 100644 src/core/config.ts delete mode 100644 src/core/http.ts delete mode 100644 src/core/sdk.ts delete mode 100644 src/im/imClient.ts delete mode 100644 src/im/imSDK.ts delete mode 100644 src/im/types.ts delete mode 100644 src/push/pushSDK.ts create mode 100644 src/sdk.ts create mode 100644 src/shims/async-storage.ts create mode 100644 src/shims/watermelondb.ts delete mode 100644 src/update/updateSDK.ts diff --git a/README.md b/README.md index d3ce6a1..260ab8e 100644 --- a/README.md +++ b/README.md @@ -1,256 +1,40 @@ -# XuqmGroup React Native SDK 文档 +# XuqmGroup React Native SDK -> TypeScript · React Native 0.76+ · 发布至 Nexus npm -> `rn-sdk` 为内部基础包,业务方正常接入时使用 `rn-common` 和各业务模块即可。 +`rn-sdk` 的稳定入口是 `src/index.ts`,统一登录/登出层在 `src/sdk.ts`。 +旧的 `src/core`、`src/im`、`src/push`、`src/update` 目录已清理,避免继续引用废弃实现。 + +## 当前结构 + +```text +XuqmGroup-RNSDK/ +├── src/ +│ ├── index.ts # 对外聚合入口 +│ └── sdk.ts # 统一登录 / 登出封装 +├── packages/ +│ ├── common/ # 初始化、网络、设备、Token、基础组件 +│ ├── im/ # IM、会话、历史、群组、关系链 +│ ├── push/ # 推送设备注册 +│ └── update/ # App 更新 / RN 热更新 +└── README.md +``` ## 安装 -`rn-common` 可以独立使用;`rn-im` / `rn-push` / `rn-update` 会自动带上 `rn-common` 和 `rn-sdk`;`rn-sdk` 是内部基础包,不建议业务方直接引用。 - ```bash -# 基础能力 -npm install @xuqm/rn-common -# 或 -yarn add @xuqm/rn-common +yarn add @xuqm/rn-sdk +yarn add @react-native-async-storage/async-storage -# 单独接入某个业务模块时,`rn-common` 会作为依赖自动安装 -npm install @xuqm/rn-im -npm install @xuqm/rn-push -npm install @xuqm/rn-update - -# 依赖安装(peerDependencies) -npm install @react-native-async-storage/async-storage +# 如需按模块拆分接入,也可以直接安装 +yarn add @xuqm/rn-common @xuqm/rn-im @xuqm/rn-push @xuqm/rn-update ``` -## 目录结构 +## 入口 -``` -XuqmGroup-RNSDK/src/ -├── core/ -│ ├── http.ts # fetch HTTP 客户端,AsyncStorage Token -│ └── sdk.ts # XuqmSDK 初始化入口 -├── im/ -│ └── imClient.ts # WebSocket IM 客户端,指数退避重连 -├── push/ -│ └── pushSDK.ts # 推送 Token 注册 -├── update/ -│ └── updateSDK.ts # 检查更新 / 下载 Bundle -└── index.ts # 统一导出 -``` +- `XuqmSDK.initialize({ appKey, debug })` +- `XuqmSDK.login({ userId, userSig, profile, expiresAt, refreshUserSig })` +- `XuqmSDK.logout()` +- `ImSDK` +- `PushSDK` +- `UpdateSDK` ---- - -## 快速开始 - -### 1. 初始化(App 入口) - -```typescript -import { XuqmSDK } from '@xuqm/rn-common' - -await XuqmSDK.init({ - appKey: 'ak_your_app_key', - debug: __DEV__, -}) -``` - -### 2. 用户登录后设置 Token - -```typescript -await XuqmSDK.setToken('eyJ...') -// 登出时 -await XuqmSDK.setToken(null) -``` - -Token 持久化存储于 `AsyncStorage`,App 重启后自动恢复。 - ---- - -## HTTP 客户端 - -```typescript -import { apiRequest } from '@xuqm/rn-common' - -// 自动附加 Bearer Token -const data = await apiRequest('/api/user/profile') -const result = await apiRequest('/api/auth/login', { - method: 'POST', - body: { account, password }, -}) -``` - ---- - -## IM 模块 - -### ImClient - -```typescript -import { ImClient, MsgType, ChatType } from '@xuqm/rn-im' - -const im = new ImClient() - -im.on('connected', () => console.log('IM 已连接')) -im.on('disconnected', (code, reason) => console.log('断开', code)) -im.on('message', (msg) => { - console.log('新消息', msg.content) - // msg: ImMessage -}) -im.on('revoke', ({ msgId, operatorId }) => { - console.log('消息被撤回', msgId) -}) -im.on('error', (err) => console.error(err)) - -// 连接 -im.connect() - -// 发送消息 -im.send({ - toId: 'user_002', - chatType: ChatType.SINGLE, - msgType: MsgType.TEXT, - content: 'Hello from RN!', -}) - -// 撤回 -im.revoke('msg-uuid') - -// 组件卸载时断开 -useEffect(() => { - im.connect() - return () => im.disconnect() -}, []) -``` - -### React Hook 封装示例 - -```typescript -import { useEffect, useState, useRef } from 'react' -import { ImClient, ImMessage } from '@xuqm/rn-im' - -export function useIm() { - const [messages, setMessages] = useState([]) - const [connected, setConnected] = useState(false) - const imRef = useRef(null) - - useEffect(() => { - const im = new ImClient() - imRef.current = im - im.on('connected', () => setConnected(true)) - im.on('disconnected', () => setConnected(false)) - im.on('message', (msg) => setMessages(prev => [...prev, msg])) - im.connect() - return () => im.disconnect() - }, []) - - const send = (params: Parameters[0]) => - imRef.current?.send(params) - - return { messages, connected, send } -} -``` - -### ImMessage 结构 - -```typescript -interface ImMessage { - id: string - fromId: string - toId: string - chatType: 'SINGLE' | 'GROUP' - msgType: MsgType - content: string - extra?: string - revoked: boolean - createdAt: string -} -``` - -### 消息类型(MsgType) - -```typescript -enum MsgType { - TEXT = 'TEXT', - IMAGE = 'IMAGE', - VIDEO = 'VIDEO', - AUDIO = 'AUDIO', - FILE = 'FILE', - CUSTOM = 'CUSTOM', - LOCATION = 'LOCATION', - NOTIFY = 'NOTIFY', - RICH_TEXT = 'RICH_TEXT', - CALL_AUDIO = 'CALL_AUDIO', - CALL_VIDEO = 'CALL_VIDEO', - FORWARD = 'FORWARD', -} -``` - -### 自动重连 - -断线后指数退避重连:3s → 6s → 12s → ... → 最大 30s。调用 `disconnect()` 后停止。 - ---- - -## 推送模块 - -```typescript -import { PushSDK } from '@xuqm/rn-push' - -// 在获取到设备推送 Token 后调用(如小米、华为推送回调) -await PushSDK.registerToken({ - appId: 'ak_xxx', - userId: 'user_001', - vendor: 'XIAOMI', // HUAWEI / XIAOMI / OPPO / VIVO / HONOR / APNS - token: 'device_push_token', -}) -``` - ---- - -## 版本管理模块 - -### 检查 App 更新 - -```typescript -import { UpdateSDK } from '@xuqm/rn-update' - -const result = await UpdateSDK.checkAppUpdate({ - appId: 'ak_xxx', - platform: 'android', // 'android' | 'ios' - currentVersionCode: 10, -}) - -if (result.needsUpdate) { - console.log(result.versionName, result.downloadUrl, result.forceUpdate) -} -``` - -### 检查 RN Bundle 热更新 - -```typescript -const rnResult = await UpdateSDK.checkRnUpdate({ - appId: 'ak_xxx', - moduleId: 'main', - platform: 'android', - currentVersion: '1.0.0', -}) - -if (rnResult.needsUpdate && rnResult.info) { - const { downloadUrl, md5 } = rnResult.info - // 下载 bundle,校验 md5,调用原生模块热加载 -} -``` - ---- - -## 发版 - -```bash -# 确认 .npmrc 指向 https://nexus.xuqinmin.com/repository/npm-hosted/ -# 登录 Nexus npm(首次) -npm login --registry=https://nexus.xuqinmin.com/repository/npm-hosted/ - -# 发版 -npm publish -``` - -版本号在 `package.json` 的 `version` 字段维护。 +详细用法见 [docs/rn-sdk/README.md](../docs/rn-sdk/README.md)。 diff --git a/packages/im/package.json b/packages/im/package.json index 2cfe8c7..ddcbb76 100644 --- a/packages/im/package.json +++ b/packages/im/package.json @@ -10,7 +10,10 @@ "publishConfig": { "registry": "https://nexus.xuqinmin.com/repository/npm-hosted/" }, - "scripts": { "typecheck": "tsc --noEmit" }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "tsc -p tsconfig.test.json && node --test dist-test/tests/runtime.test.js" + }, "dependencies": { "@xuqm/rn-common": ">=0.2.0", "@xuqm/rn-sdk": ">=0.2.0" diff --git a/packages/im/src/ImSDK.ts b/packages/im/src/ImSDK.ts index 69c6b76..2313d29 100644 --- a/packages/im/src/ImSDK.ts +++ b/packages/im/src/ImSDK.ts @@ -16,6 +16,7 @@ import type { UserProfile, } from './types' import { uploadFile } from './upload' +import { buildDbName, sortConversations as sortConversationsByRuntime } from './runtime' let client: ImClient | null = null let _currentUserId: string | null = null @@ -115,19 +116,12 @@ function normalizeConversation(item: { } } -function sortConversations(conversations: ConversationData[]): ConversationData[] { - return [...conversations].sort((a, b) => { - if (a.isPinned !== b.isPinned) return Number(b.isPinned) - Number(a.isPinned) - return b.lastMsgTime - a.lastMsgTime - }) -} - function conversationKey(targetId: string, chatType: ChatType): string { return `${chatType}:${targetId}` } function emitConversationMemory(): void { - const snapshot = sortConversations(conversationMemory) + const snapshot = sortConversationsByRuntime(conversationMemory) conversationMemory = snapshot conversationListeners.forEach(listener => listener(snapshot)) } @@ -295,6 +289,7 @@ export const ImSDK = { async login(userId: string, nickname?: string, avatar?: string, dbName?: string): Promise { const config = getConfig() const device = await getDeviceInfo() + client?.disconnect() const res = await apiRequest<{ token: string }>('/api/im/auth/login', { method: 'POST', skipAuth: true, @@ -314,9 +309,7 @@ export const ImSDK = { _currentUserId = userId setCommonUserId(userId) - if (dbName !== undefined || ImDatabase.isInitialized()) { - ImDatabase.init(dbName ?? 'xuqm_im') - } + ImDatabase.init(dbName ?? buildDbName(config.appKey, userId)) client = new ImClient(config.imWsUrl, res.token, config.appId) client.addListener({ @@ -333,13 +326,12 @@ export const ImSDK = { */ async loginWithToken(userId: string, token: string, dbName?: string): Promise { const config = getConfig() + client?.disconnect() await _saveToken(token) _currentUserId = userId setCommonUserId(userId) - if (dbName !== undefined || ImDatabase.isInitialized()) { - ImDatabase.init(dbName ?? 'xuqm_im') - } + ImDatabase.init(dbName ?? buildDbName(config.appKey, userId)) client = new ImClient(config.imWsUrl, token, config.appId) client.addListener({ @@ -350,6 +342,10 @@ export const ImSDK = { void client.connect() }, + async loginWithUserSig(userId: string, userSig: string, dbName?: string): Promise { + return ImSDK.loginWithToken(userId, userSig, dbName) + }, + async reconnect(): Promise { const config = getConfig() const token = await _getToken() @@ -1044,7 +1040,7 @@ export const ImSDK = { subscribeConversations(callback: (conversations: ConversationData[]) => void): () => void { if (!ImDatabase.isInitialized()) { conversationListeners.add(callback) - callback(sortConversations(conversationMemory)) + callback(sortConversationsByRuntime(conversationMemory)) return () => { conversationListeners.delete(callback) } diff --git a/packages/im/src/db/ImDatabase.ts b/packages/im/src/db/ImDatabase.ts index 6fee9cc..f646eea 100644 --- a/packages/im/src/db/ImDatabase.ts +++ b/packages/im/src/db/ImDatabase.ts @@ -6,6 +6,7 @@ import { MessageModel } from './MessageModel' import type { ImMessage } from '../types' let _db: Database | null = null +let _dbName: string | null = null const draftStore = new Map() function getDb(): Database { @@ -36,7 +37,7 @@ export interface MessageSearchParams { export const ImDatabase = { init(dbName = 'xuqm_im') { - if (_db) return + if (_db && _dbName === dbName) return const adapter = new SQLiteAdapter({ schema: imDbSchema, dbName, @@ -47,6 +48,7 @@ export const ImDatabase = { adapter, modelClasses: [ConversationModel, MessageModel], }) + _dbName = dbName }, async saveMessage(msg: ImMessage, currentUserId: string): Promise { diff --git a/packages/im/src/runtime.ts b/packages/im/src/runtime.ts new file mode 100644 index 0000000..3aa51e1 --- /dev/null +++ b/packages/im/src/runtime.ts @@ -0,0 +1,28 @@ +import type { ConversationData, ImMessage } from './types' + +export function buildDbName(appKey: string, userId: string): string { + return `xuqm_im_${appKey}_${userId}` +} + +export function sortMessages(messages: ImMessage[]): ImMessage[] { + const deduped = new Map() + messages.forEach((message) => { + const existing = deduped.get(message.id) + if (!existing || message.createdAt >= existing.createdAt) { + deduped.set(message.id, message) + } + }) + return [...deduped.values()] + .sort((a, b) => { + if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt + return b.id.localeCompare(a.id) + }) +} + +export function sortConversations(conversations: ConversationData[]): ConversationData[] { + return [...conversations].sort((a, b) => { + if (a.isPinned !== b.isPinned) return Number(b.isPinned) - Number(a.isPinned) + if (b.lastMsgTime !== a.lastMsgTime) return b.lastMsgTime - a.lastMsgTime + return `${a.chatType}:${a.targetId}`.localeCompare(`${b.chatType}:${b.targetId}`) + }) +} diff --git a/packages/im/src/upload.ts b/packages/im/src/upload.ts index 0e711f9..f59a85c 100644 --- a/packages/im/src/upload.ts +++ b/packages/im/src/upload.ts @@ -24,7 +24,7 @@ export async function uploadFile( const config = getConfig() const token = await _getToken() if (!token) { - throw new Error('[uploadFile] No active session — call ImSDK.login() first.') + throw new Error('[uploadFile] No active session — call ImSDK.loginWithToken() first.') } const form = new FormData() diff --git a/packages/im/tests/runtime.test.ts b/packages/im/tests/runtime.test.ts new file mode 100644 index 0000000..1eb24f6 --- /dev/null +++ b/packages/im/tests/runtime.test.ts @@ -0,0 +1,34 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildDbName, sortConversations, sortMessages } from '../src/runtime' + +test('buildDbName derives a stable per-user database name', () => { + assert.equal(buildDbName('ak_demo_chat', 'demo_alice'), 'xuqm_im_ak_demo_chat_demo_alice') +}) + +test('sortConversations keeps pinned conversations first and then sorts by time', () => { + const conversations = sortConversations([ + { targetId: 'c', chatType: 'SINGLE', lastMsgTime: 100, unreadCount: 0, isMuted: false, isPinned: false }, + { targetId: 'a', chatType: 'SINGLE', lastMsgTime: 80, unreadCount: 0, isMuted: false, isPinned: true }, + { targetId: 'b', chatType: 'GROUP', lastMsgTime: 120, unreadCount: 0, isMuted: false, isPinned: false }, + { targetId: 'd', chatType: 'GROUP', lastMsgTime: 120, unreadCount: 0, isMuted: false, isPinned: false }, + ]) + + assert.deepEqual( + conversations.map((item) => `${item.chatType}:${item.targetId}`), + ['SINGLE:a', 'GROUP:b', 'GROUP:d', 'SINGLE:c'], + ) +}) + +test('sortMessages deduplicates by message id and sorts newest first', () => { + const messages = sortMessages([ + { id: 'm2', appId: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'old', status: 'SENT', createdAt: 100 }, + { id: 'm1', appId: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'latest', status: 'SENT', createdAt: 200 }, + { id: 'm1', appId: 'ak', fromUserId: 'u1', toId: 'peer', chatType: 'SINGLE', msgType: 'TEXT', content: 'duplicate', status: 'READ', createdAt: 180 }, + ]) + + assert.deepEqual( + messages.map((item) => `${item.id}:${item.createdAt}:${item.content}`), + ['m1:200:latest', 'm2:100:old'], + ) +}) diff --git a/packages/im/tsconfig.test.json b/packages/im/tsconfig.test.json new file mode 100644 index 0000000..323ba8f --- /dev/null +++ b/packages/im/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "rootDir": ".", + "outDir": "dist-test", + "noEmit": false, + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["src/runtime.ts", "src/types.ts", "tests/**/*.ts"], + "exclude": ["node_modules", "dist", "dist-test"] +} diff --git a/packages/push/src/PushSDK.ts b/packages/push/src/PushSDK.ts index abd01aa..8cce310 100644 --- a/packages/push/src/PushSDK.ts +++ b/packages/push/src/PushSDK.ts @@ -1,9 +1,48 @@ -import { apiRequest, getConfig, getDeviceInfo } from '@xuqm/rn-common' +import { apiRequest, getConfig, getDeviceInfo, getUserId as getCommonUserId } from '@xuqm/rn-common' import type { PushVendor } from '@xuqm/rn-common' export type { PushVendor } +type PendingDeviceToken = { + token: string + vendor?: PushVendor +} + +let currentUserId: string | null = null +let pendingToken: PendingDeviceToken | null = null + +async function registerPendingToken(): Promise { + const userId = currentUserId ?? getCommonUserId() + if (!userId || !pendingToken) return + const device = await getDeviceInfo() + const config = getConfig() + await apiRequest('/api/push/register', { + method: 'POST', + params: { + appId: config.appId, + userId, + vendor: pendingToken.vendor ?? device.pushVendor, + token: pendingToken.token, + platform: device.platform, + deviceId: device.deviceId, + brand: device.brand, + model: device.model, + osVersion: device.osVersion, + }, + }) +} + export const PushSDK = { + async initialize(userId?: string): Promise { + currentUserId = userId ?? getCommonUserId() + await registerPendingToken() + }, + + async setDeviceToken(token: string, vendor?: PushVendor): Promise { + pendingToken = { token, vendor } + await registerPendingToken() + }, + /** * Register a push device token for the given user. * If vendor is omitted, it is auto-detected from the device brand. @@ -13,6 +52,8 @@ export const PushSDK = { * @param vendor - Optional; auto-detected when not provided */ async registerToken(userId: string, token: string, vendor?: PushVendor): Promise { + currentUserId = userId + pendingToken = { token, vendor } const config = getConfig() const device = await getDeviceInfo() await apiRequest('/api/push/register', { @@ -38,5 +79,13 @@ export const PushSDK = { method: 'DELETE', params: { appId: config.appId, userId, deviceId }, }) + if (currentUserId === userId) currentUserId = null + if (pendingToken && currentUserId === null) pendingToken = null + }, + + async logout(userId?: string): Promise { + const targetUserId = userId ?? currentUserId ?? getCommonUserId() + if (!targetUserId) return + await PushSDK.unregisterToken(targetUserId) }, } diff --git a/src/core/config.ts b/src/core/config.ts deleted file mode 100644 index d1c6521..0000000 --- a/src/core/config.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface XuqmSDKConfig { - appKey: string - appSecret: string - debug?: boolean -} - -let _config: XuqmSDKConfig | null = null - -export function initSDK(config: XuqmSDKConfig) { - _config = config -} - -export function getConfig(): XuqmSDKConfig { - if (!_config) throw new Error('XuqmSDK not initialized. Call initSDK() first.') - return _config -} diff --git a/src/core/http.ts b/src/core/http.ts deleted file mode 100644 index b019708..0000000 --- a/src/core/http.ts +++ /dev/null @@ -1,49 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { getConfig } from './config' - -const TOKEN_KEY = '@xuqm_sdk_token' -const DEFAULT_API_URL = 'https://dev.xuqinmin.com' - -export async function getToken(): Promise { - return AsyncStorage.getItem(TOKEN_KEY) -} - -export async function saveToken(token: string): Promise { - return AsyncStorage.setItem(TOKEN_KEY, token) -} - -export async function clearToken(): Promise { - return AsyncStorage.removeItem(TOKEN_KEY) -} - -export async function apiRequest( - path: string, - options: { method?: string; body?: unknown; params?: Record } = {}, -): Promise { - const config = getConfig() - const token = await getToken() - - let url = DEFAULT_API_URL.replace(/\/$/, '') + path - if (options.params) { - const qs = new URLSearchParams(options.params).toString() - url += (url.includes('?') ? '&' : '?') + qs - } - - const res = await fetch(url, { - method: options.method ?? 'GET', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: options.body ? JSON.stringify(options.body) : undefined, - }) - - if (!res.ok) { - const err = await res.json().catch(() => ({ message: res.statusText })) - throw new Error(err.message ?? `HTTP ${res.status}`) - } - - const json = await res.json() - return json.data ?? json -} diff --git a/src/core/sdk.ts b/src/core/sdk.ts deleted file mode 100644 index 26e2133..0000000 --- a/src/core/sdk.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { getConfig, initSDK, type XuqmSDKConfig } from './config' -import { clearToken, getToken, saveToken } from './http' - -export const XuqmSDK = { - init(config: XuqmSDKConfig) { - initSDK(config) - }, - - getConfig, - - async getToken() { - return getToken() - }, - - async setToken(token: string | null) { - if (token) { - await saveToken(token) - return - } - await clearToken() - }, - - async clearToken() { - await clearToken() - }, -} diff --git a/src/im/imClient.ts b/src/im/imClient.ts deleted file mode 100644 index 5403692..0000000 --- a/src/im/imClient.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { ImEventListener, ImMessage, SendMessageParams } from './types' - -interface StompFrame { - command: string - headers: Record - body: string -} - -export class ImClient { - private ws: WebSocket | null = null - private listeners: ImEventListener[] = [] - private reconnectTimer: ReturnType | null = null - private reconnectDelay = 3000 - private shouldReconnect = true - private readonly subscriptionId = 'sub-user-queue' - private groupSubscriptions = new Set() - - constructor( - private readonly wsUrl: string, - private readonly token: string, - private readonly appId: string, - ) {} - - connect() { - this.shouldReconnect = true - this.openSocket() - } - - sendMessage( - toId: string, - chatType: SendMessageParams['chatType'], - msgType: SendMessageParams['msgType'], - content: string, - mentionedUserIds?: string, - ) { - this.send({ - toId, - chatType, - msgType, - content, - mentionedUserIds, - }) - } - - send(params: SendMessageParams) { - if (this.ws?.readyState !== WebSocket.OPEN) { - throw new Error('IM not connected') - } - - this.sendFrame( - 'SEND', - { - destination: '/app/chat.send', - 'content-type': 'application/json', - }, - JSON.stringify({ - appId: this.appId, - toId: params.toId, - chatType: params.chatType, - msgType: params.msgType, - content: params.content, - mentionedUserIds: params.mentionedUserIds ?? '', - }), - ) - } - - revoke(messageId: string) { - if (this.ws?.readyState !== WebSocket.OPEN) { - throw new Error('IM not connected') - } - - this.sendFrame( - 'SEND', - { - destination: '/app/chat.revoke', - 'content-type': 'application/json', - }, - JSON.stringify({ - appId: this.appId, - messageId, - }), - ) - } - - subscribeGroup(groupId: string) { - this.groupSubscriptions.add(groupId) - if (this.ws?.readyState === WebSocket.OPEN) { - this.subscribe(`/topic/group/${groupId}`, `group-${groupId}`) - } - } - - addListener(listener: ImEventListener) { - this.listeners.push(listener) - } - - removeListener(listener: ImEventListener) { - this.listeners = this.listeners.filter(item => item !== listener) - } - - disconnect() { - this.shouldReconnect = false - if (this.reconnectTimer) clearTimeout(this.reconnectTimer) - if (this.ws?.readyState === WebSocket.OPEN) { - this.sendFrame('DISCONNECT') - } - this.ws?.close(1000, 'User disconnect') - this.ws = null - } - - isConnected(): boolean { - return this.ws?.readyState === WebSocket.OPEN - } - - private openSocket() { - this.ws = new WebSocket(this.wsUrl) - - this.ws.onopen = () => { - this.sendFrame('CONNECT', { - 'accept-version': '1.2', - Authorization: `Bearer ${this.token}`, - 'heart-beat': '10000,10000', - }) - } - - this.ws.onmessage = event => { - try { - const frames = this.parseFrames(String(event.data)) - frames.forEach(frame => this.handleFrame(frame)) - } catch { - this.listeners.forEach(listener => listener.onError?.('Parse error')) - } - } - - this.ws.onclose = event => { - this.listeners.forEach(listener => listener.onDisconnected?.(event.reason)) - if (this.shouldReconnect) { - this.reconnectTimer = setTimeout(() => { - this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000) - this.openSocket() - }, this.reconnectDelay) - } - } - - this.ws.onerror = () => { - this.listeners.forEach(listener => listener.onError?.('WebSocket error')) - } - } - - private handleFrame(frame: StompFrame) { - if (frame.command === 'CONNECTED') { - this.reconnectDelay = 3000 - this.subscribe('/user/queue/messages', this.subscriptionId) - this.groupSubscriptions.forEach(groupId => { - this.subscribe(`/topic/group/${groupId}`, `group-${groupId}`) - }) - this.listeners.forEach(listener => listener.onConnected?.()) - return - } - - if (frame.command === 'MESSAGE') { - const message: ImMessage = JSON.parse(frame.body) - if (message.chatType === 'GROUP') { - this.listeners.forEach(listener => listener.onGroupMessage?.(message)) - return - } - this.listeners.forEach(listener => listener.onMessage?.(message)) - return - } - - if (frame.command === 'ERROR') { - this.listeners.forEach(listener => listener.onError?.(frame.body || 'WebSocket error')) - } - } - - private subscribe(destination: string, id: string) { - this.sendFrame('SUBSCRIBE', { destination, id }) - } - - private sendFrame(command: string, headers: Record = {}, body = '') { - if (!this.ws) return - const headerLines = Object.entries(headers) - .map(([key, value]) => `${key}:${value}`) - .join('\n') - const frame = `${command}\n${headerLines}\n\n${body}\u0000` - this.ws.send(frame) - } - - private parseFrames(raw: string): StompFrame[] { - return raw - .split('\u0000') - .map(frame => frame.replace(/^\n+/, '').trim()) - .filter(Boolean) - .map(frame => { - const separatorIndex = frame.indexOf('\n\n') - const headerBlock = separatorIndex >= 0 ? frame.slice(0, separatorIndex) : frame - const body = separatorIndex >= 0 ? frame.slice(separatorIndex + 2) : '' - const [command, ...headerLines] = headerBlock.split('\n').filter(Boolean) - const headers = Object.fromEntries( - headerLines - .filter(line => line.includes(':')) - .map(line => { - const index = line.indexOf(':') - return [line.slice(0, index), line.slice(index + 1)] - }), - ) - return { command, headers, body } - }) - } -} diff --git a/src/im/imSDK.ts b/src/im/imSDK.ts deleted file mode 100644 index 7a546c8..0000000 --- a/src/im/imSDK.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { getConfig } from '../core/config' -import { apiRequest, getToken, saveToken } from '../core/http' -import { ImClient } from './imClient' -import type { ChatType, ImEventListener, ImGroup, ImMessage, MsgType } from './types' - -let client: ImClient | null = null -const DEFAULT_IM_WS_URL = 'wss://dev.xuqinmin.com/ws/im' - -export const ImSDK = { - async login(userId: string, nickname?: string, avatar?: string): Promise { - const config = getConfig() - const res = await apiRequest<{ token: string }>('/api/im/auth/login', { - method: 'POST', - params: { - appId: config.appKey, - userId, - ...(nickname ? { nickname } : {}), - ...(avatar ? { avatar } : {}), - }, - }) - await saveToken(res.token) - client = new ImClient(DEFAULT_IM_WS_URL, res.token, config.appKey) - client.connect() - }, - - async reconnect(): Promise { - const config = getConfig() - const token = await getToken() - if (!token) throw new Error('ImSDK: token not found') - client = new ImClient(DEFAULT_IM_WS_URL, token, config.appKey) - client.connect() - }, - - async fetchHistory(toId: string, page = 0, size = 20): Promise { - const config = getConfig() - const res = await apiRequest<{ content?: ImMessage[] } | ImMessage[]>(`/api/im/messages/history/${encodeURIComponent(toId)}`, { - params: { - appId: config.appKey, - page: String(page), - size: String(size), - }, - }) - return Array.isArray(res) ? res : (res.content ?? []) - }, - - async sendMessage( - toId: string, - chatType: ChatType, - msgType: MsgType, - content: string, - mentionedUserIds?: string, - ): Promise { - const config = getConfig() - return apiRequest('/api/im/messages/send', { - method: 'POST', - params: { appId: config.appKey }, - body: { - toId, - chatType, - msgType, - content, - mentionedUserIds: mentionedUserIds ?? '', - }, - }) - }, - - async revokeMessage(messageId: string): Promise { - const config = getConfig() - return apiRequest(`/api/im/messages/${encodeURIComponent(messageId)}/revoke`, { - method: 'POST', - params: { appId: config.appKey }, - }) - }, - - async editMessage(messageId: string, content: string): Promise { - const config = getConfig() - return apiRequest(`/api/im/messages/${encodeURIComponent(messageId)}`, { - method: 'PUT', - params: { appId: config.appKey }, - body: { content }, - }) - }, - - addListener(listener: ImEventListener) { - client?.addListener(listener) - }, - - removeListener(listener: ImEventListener) { - client?.removeListener(listener) - }, - - disconnect() { - client?.disconnect() - client = null - }, - - subscribeGroup(groupId: string) { - client?.subscribeGroup(groupId) - }, - - isConnected() { - return client?.isConnected() ?? false - }, - - async createGroup(name: string, memberIds: string[]): Promise { - const config = getConfig() - return apiRequest('/api/im/groups', { - method: 'POST', - params: { appId: config.appKey }, - body: { name, memberIds }, - }) - }, - - async listGroups(): Promise { - const config = getConfig() - const res = await apiRequest('/api/im/groups', { - params: { appId: config.appKey }, - }) - return Array.isArray(res) ? res : (res.content ?? []) - }, - - async fetchGroupHistory(groupId: string, page = 0, size = 50): Promise { - const config = getConfig() - const res = await apiRequest<{ content?: ImMessage[] } | ImMessage[]>( - `/api/im/messages/history/${encodeURIComponent(groupId)}`, - { - params: { - appId: config.appKey, - page: String(page), - size: String(size), - }, - }, - ) - return Array.isArray(res) ? res : (res.content ?? []) - }, -} diff --git a/src/im/types.ts b/src/im/types.ts deleted file mode 100644 index 03dc3f4..0000000 --- a/src/im/types.ts +++ /dev/null @@ -1,58 +0,0 @@ -export type ChatType = 'SINGLE' | 'GROUP' - -export type MsgType = - | 'TEXT' - | 'IMAGE' - | 'VIDEO' - | 'AUDIO' - | 'FILE' - | 'CUSTOM' - | 'LOCATION' - | 'NOTIFY' - | 'RICH_TEXT' - | 'CALL_AUDIO' - | 'CALL_VIDEO' - | 'REVOKED' - | 'FORWARD' - -export type MsgStatus = 'SENT' | 'DELIVERED' | 'READ' | 'REVOKED' - -export interface ImMessage { - id: string - appId: string - fromUserId: string - toId: string - chatType: ChatType - msgType: MsgType - content: string - status: MsgStatus - mentionedUserIds?: string - createdAt: string - editedAt?: string | null -} - -export interface ImEventListener { - onConnected?: () => void - onDisconnected?: (reason?: string) => void - onMessage?: (msg: ImMessage) => void - onGroupMessage?: (msg: ImMessage) => void - onError?: (error: string) => void -} - -export interface SendMessageParams { - toId: string - chatType: ChatType - msgType: MsgType - content: string - mentionedUserIds?: string -} - -export interface ImGroup { - id: string - appId: string - name: string - creatorId: string - memberIds: string - adminIds: string - createdAt: string -} diff --git a/src/index.ts b/src/index.ts index dae4e4b..9392a96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,27 @@ -// @xuqm/rn-sdk — hidden core package. -// App code should import @xuqm/rn-common directly; this package is installed -// transitively by @xuqm/rn-im / @xuqm/rn-push / @xuqm/rn-update. - -export { XuqmSDK } from '@xuqm/rn-common' +export { XuqmSDK } from './sdk' +export type { UnifiedLoginOptions, UnifiedLoginRefreshResult } from './sdk' export type { XuqmInitOptions, DeviceInfo } from '@xuqm/rn-common' export { getDeviceId, getDeviceInfo, detectPushVendor, setUserId, getUserId } from '@xuqm/rn-common' export { ScaledImage } from '@xuqm/rn-common' export { apiRequest } from '@xuqm/rn-common' +export { ImSDK, ImClient, ImDatabase, uploadFile } from '@xuqm/rn-im' +export type { + ImMessage, + ImGroup, + ChatType, + MsgType, + MsgStatus, + ImEventListener, + SendMessageParams, + ConversationData, + HistoryQuery, + PageResult, + FriendRequest, + GroupJoinRequest, + BlacklistEntry, + UserProfile, +} from '@xuqm/rn-im' +export { PushSDK } from '@xuqm/rn-push' +export type { PushVendor } from '@xuqm/rn-push' +export { UpdateSDK } from '@xuqm/rn-update' +export type { PluginMeta, AppUpdateInfo, RnUpdateInfo, CachedRnBundle } from '@xuqm/rn-update' diff --git a/src/push/pushSDK.ts b/src/push/pushSDK.ts deleted file mode 100644 index ef2b0dd..0000000 --- a/src/push/pushSDK.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Platform } from 'react-native' -import { apiRequest } from '../core/http' -import { getConfig } from '../core/config' - -export type PushVendor = 'HUAWEI' | 'XIAOMI' | 'OPPO' | 'VIVO' | 'HONOR' | 'APNS' | 'FCM' - -export const PushSDK = { - async registerToken(userId: string, vendor: PushVendor, token: string): Promise { - const config = getConfig() - await apiRequest('/api/push/register', { - method: 'POST', - params: { - appId: config.appKey, - userId, - vendor, - token, - }, - }) - }, - - async unregisterToken(userId: string): Promise { - const config = getConfig() - await apiRequest('/api/push/unregister', { - method: 'DELETE', - params: { appId: config.appKey, userId }, - }) - }, -} diff --git a/src/sdk.ts b/src/sdk.ts new file mode 100644 index 0000000..3653e66 --- /dev/null +++ b/src/sdk.ts @@ -0,0 +1,117 @@ +import { _clearToken, setUserId as setCommonUserId, getUserId as getCommonUserId, XuqmSDK as CommonXuqmSDK } from '@xuqm/rn-common' +import { ImSDK } from '@xuqm/rn-im' +import { PushSDK } from '@xuqm/rn-push' +import type { UserProfile } from '@xuqm/rn-im' + +export interface UnifiedLoginRefreshResult { + userSig: string + expiresAt?: number +} + +export interface UnifiedLoginOptions { + userId: string + userSig: string + profile?: Pick + expiresAt?: number + refreshUserSig?: () => Promise +} + +type SessionState = UnifiedLoginOptions + +const REFRESH_GRACE_MS = 5 * 60 * 1000 +const REFRESH_RETRY_MS = 30 * 1000 + +let currentSession: SessionState | null = null +let refreshTimer: ReturnType | null = null +let refreshInFlight = false + +function clearRefreshTimer(): void { + if (refreshTimer) { + clearTimeout(refreshTimer) + refreshTimer = null + } +} + +async function applyLoginSession(session: SessionState): Promise { + clearRefreshTimer() + setCommonUserId(session.userId) + try { + await ImSDK.loginWithUserSig(session.userId, session.userSig) + } catch (error) { + setCommonUserId(null) + currentSession = null + throw error + } + currentSession = session + try { + await PushSDK.initialize(session.userId) + } catch (error) { + void error + } + scheduleRefresh() +} + +async function refreshSession(): Promise { + const session = currentSession + if (refreshInFlight || !session?.refreshUserSig) return + refreshInFlight = true + try { + const refreshed = await session.refreshUserSig() + if (currentSession !== session) return + await applyLoginSession({ + ...session, + userSig: refreshed.userSig, + expiresAt: refreshed.expiresAt ?? session.expiresAt, + }) + } catch (error) { + clearRefreshTimer() + refreshTimer = setTimeout(() => { + void refreshSession() + }, REFRESH_RETRY_MS) + if (CommonXuqmSDK.getUserId() && process.env.NODE_ENV !== 'production') { + console.warn('[XuqmSDK] userSig refresh failed, will retry', error) + } + } finally { + refreshInFlight = false + } +} + +function scheduleRefresh(): void { + clearRefreshTimer() + const session = currentSession + if (!session?.refreshUserSig || !session.expiresAt) return + const delayMs = Math.max(session.expiresAt - Date.now() - REFRESH_GRACE_MS, 0) + refreshTimer = setTimeout(() => { + void refreshSession() + }, delayMs) +} + +async function login(options: UnifiedLoginOptions): Promise { + if (currentSession) { + await logout() + } + await applyLoginSession(options) +} + +async function logout(): Promise { + clearRefreshTimer() + const userId = currentSession?.userId ?? getCommonUserId() + currentSession = null + refreshInFlight = false + setCommonUserId(null) + if (userId) { + try { + await PushSDK.logout(userId) + } catch (error) { + void error + } + } + ImSDK.disconnect() + await _clearToken() +} + +export const XuqmSDK = { + ...CommonXuqmSDK, + login, + logout, +} diff --git a/src/shims/async-storage.ts b/src/shims/async-storage.ts new file mode 100644 index 0000000..57b6ea2 --- /dev/null +++ b/src/shims/async-storage.ts @@ -0,0 +1,19 @@ +const AsyncStorage = { + async getItem(_key: string): Promise { + return null + }, + + async setItem(_key: string, _value: string): Promise { + return + }, + + async removeItem(_key: string): Promise { + return + }, + + async clear(): Promise { + return + }, +} + +export default AsyncStorage diff --git a/src/shims/watermelondb.ts b/src/shims/watermelondb.ts new file mode 100644 index 0000000..6cd67b2 --- /dev/null +++ b/src/shims/watermelondb.ts @@ -0,0 +1,67 @@ +export class Model { + static table = '' + + async update(updater: (record: this) => void | Promise): Promise { + await updater(this) + return this + } + + async destroyPermanently(): Promise { + return + } +} + +export class Database { + constructor(_config: { adapter: unknown; modelClasses: Array unknown> }) {} + + async write(action: () => Promise): Promise { + return action() + } + + get(_tableName: string): any { + return { + query: (..._queryParts: unknown[]) => ({ + fetch: async () => [] as T[], + extend: () => ({ + fetch: async () => [] as T[], + observe: () => ({ subscribe: () => undefined }), + }), + observe: () => ({ subscribe: () => undefined }), + }), + create: async (_builder: (record: T) => void | Promise) => ({} as T), + } + } +} + +export const Q: any = { + where: () => undefined, + take: () => undefined, + skip: () => undefined, + sortBy: () => undefined, + oneOf: () => undefined, + like: () => undefined, + gte: () => undefined, + lte: () => undefined, + sanitizeLikeString: (value: string) => value, + desc: undefined, +} + +export function appSchema(input: unknown): any { + return input +} + +export function tableSchema(input: unknown): any { + return input +} + +export function field(_name: string): any { + return () => undefined +} + +export function date(_name: string): any { + return () => undefined +} + +export default class SQLiteAdapter { + constructor(_config: { schema: unknown; dbName: string; jsi?: boolean; onSetUpError?: (err: unknown) => void }) {} +} diff --git a/src/types/async-storage.d.ts b/src/types/async-storage.d.ts index aebba0a..f46e579 100644 --- a/src/types/async-storage.d.ts +++ b/src/types/async-storage.d.ts @@ -1,9 +1,11 @@ declare module '@react-native-async-storage/async-storage' { - const AsyncStorage: { + export interface AsyncStorageStatic { getItem(key: string): Promise setItem(key: string, value: string): Promise removeItem(key: string): Promise + clear(): Promise } + const AsyncStorage: AsyncStorageStatic export default AsyncStorage } diff --git a/src/types/watermelondb.d.ts b/src/types/watermelondb.d.ts index 79b6e78..1cfe318 100644 --- a/src/types/watermelondb.d.ts +++ b/src/types/watermelondb.d.ts @@ -1,61 +1,30 @@ declare module '@nozbe/watermelondb' { export class Model { static table: string - update(mutator: (record: this) => void | Promise): Promise + constructor(...args: unknown[]) + update(updater: (record: this) => void | Promise): Promise destroyPermanently(): Promise } export class Database { - constructor(options: unknown) - write(work: () => Promise | T): Promise - get(table: string): Collection + constructor(config: { adapter: unknown; modelClasses: Array unknown> }) + write(action: () => Promise): Promise + get(tableName: string): any } - export interface Collection { - query(...conditions: unknown[]): Query - create(builder: (record: T) => void | Promise): Promise - } + export const Q: any - export interface Query { - fetch(): Promise - extend(...conditions: unknown[]): Query - observe(): Observable - } + export function appSchema(input: unknown): any + export function tableSchema(input: unknown): any +} - export interface Observable { - subscribe(callback: (value: T) => void): Subscription - } - - export interface Subscription { - unsubscribe(): void - } - - export const Q: { - and(...conditions: unknown[]): unknown - or(...conditions: unknown[]): unknown - where(column: string, value: unknown): unknown - sortBy(column: string, order?: unknown): unknown - take(count: number): unknown - skip(count: number): unknown - oneOf(values: unknown[]): unknown - like(pattern: string): unknown - gte(value: unknown): unknown - lte(value: unknown): unknown - sanitizeLikeString(value: string): string - desc: unknown - } - - export function appSchema(schema: unknown): unknown - export function tableSchema(schema: unknown): unknown +declare module '@nozbe/watermelondb/decorators' { + export const field: any + export const date: any } declare module '@nozbe/watermelondb/adapters/sqlite' { export default class SQLiteAdapter { - constructor(options: unknown) + constructor(config: { schema: unknown; dbName: string; jsi?: boolean; onSetUpError?: (err: unknown) => void }) } } - -declare module '@nozbe/watermelondb/decorators' { - export function field(columnName: string): any - export function date(columnName: string): any -} diff --git a/src/update/updateSDK.ts b/src/update/updateSDK.ts deleted file mode 100644 index 9fb7716..0000000 --- a/src/update/updateSDK.ts +++ /dev/null @@ -1,130 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { Linking, Platform } from 'react-native' -import { getConfig } from '../core/config' -import { apiRequest } from '../core/http' - -export interface AppUpdateInfo { - needsUpdate: boolean - versionName?: string - versionCode?: number - downloadUrl?: string - changeLog?: string - forceUpdate?: boolean - appStoreUrl?: string - marketUrl?: string -} - -export interface RnUpdateInfo { - needsUpdate: boolean - latestVersion: string - downloadUrl: string - md5: string - minCommonVersion: string - note: string - packageName?: string - packageMatched?: boolean -} - -export interface CachedRnBundle { - moduleId: string - version: string - md5: string - downloadedAt: string - source: string -} - -function getBundleCacheKey(moduleId: string) { - return `@xuqm_sdk_rn_bundle:${moduleId}` -} - -function normalizeDownloadUrl(rawUrl?: string) { - if (!rawUrl) return rawUrl - - // Compatible with legacy server config where update base URL already includes /api/v1/updates. - if (rawUrl.includes('/api/v1/updates/api/v1/rn/files/')) { - return rawUrl.replace('/api/v1/updates/api/v1/rn/files/', '/api/v1/rn/files/') - } - - if (rawUrl.includes('/files/apk/')) { - try { - const url = new URL(rawUrl) - if (url.pathname.startsWith('/files/apk/')) { - return `${url.origin}/api/v1/updates${url.pathname}${url.search}` - } - } catch { - return rawUrl - } - } - - return rawUrl -} - -export const UpdateSDK = { - async checkAppUpdate(currentVersionCode: number): Promise { - const config = getConfig() - const result = await apiRequest('/api/v1/updates/app/check', { - params: { - appId: config.appKey, - platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS', - currentVersionCode: String(currentVersionCode), - }, - }) - return { - ...result, - downloadUrl: normalizeDownloadUrl(result.downloadUrl), - } - }, - - async openAppStore(appStoreUrl?: string, marketUrl?: string): Promise { - const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl - if (url) await Linking.openURL(url) - }, - - async checkRnUpdate(moduleId: string, currentVersion: string, packageName?: string): Promise { - const config = getConfig() - const result = await apiRequest('/api/v1/rn/update/check', { - params: { - appId: config.appKey, - moduleId, - platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS', - currentVersion, - ...(packageName ? { packageName } : {}), - }, - }) - if (packageName && result.packageMatched === false) { - return { - ...result, - needsUpdate: false, - } - } - return { - ...result, - downloadUrl: normalizeDownloadUrl(result.downloadUrl) ?? result.downloadUrl, - } - }, - - async downloadRnBundle(downloadUrl: string): Promise { - const response = await fetch(downloadUrl) - if (!response.ok) { - throw new Error(`Failed to download bundle: ${response.status}`) - } - return response.text() - }, - - async cacheRnBundle(moduleId: string, version: string, md5: string, source: string): Promise { - const payload: CachedRnBundle = { - moduleId, - version, - md5, - source, - downloadedAt: new Date().toISOString(), - } - await AsyncStorage.setItem(getBundleCacheKey(moduleId), JSON.stringify(payload)) - return payload - }, - - async getCachedRnBundle(moduleId: string): Promise { - const raw = await AsyncStorage.getItem(getBundleCacheKey(moduleId)) - return raw ? JSON.parse(raw) as CachedRnBundle : null - }, -} diff --git a/tsconfig.json b/tsconfig.json index 460ff75..199be8b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,12 +5,17 @@ "moduleResolution": "bundler", "baseUrl": ".", "paths": { + "@react-native-async-storage/async-storage": ["src/shims/async-storage.ts"], + "@nozbe/watermelondb": ["src/shims/watermelondb.ts"], + "@nozbe/watermelondb/decorators": ["src/shims/watermelondb.ts"], + "@nozbe/watermelondb/adapters/sqlite": ["src/shims/watermelondb.ts"], "@xuqm/rn-common": ["packages/common/src"], "@xuqm/rn-im": ["packages/im/src"], "@xuqm/rn-push": ["packages/push/src"], "@xuqm/rn-update": ["packages/update/src"] }, "strict": true, + "experimentalDecorators": true, "jsx": "react-native", "lib": ["ES2020"], "declaration": true,