重构: df-miniapp移入apps/(monorepo分层 crates=Rust库/apps=前端应用)
- df-miniapp/ -> apps/df-miniapp/(uni-app 工程归集前端应用层,与 crates/ Rust 库分层) - 删冗余 apps/df-miniapp/.gitignore(根已覆盖 node_modules/dist/.DS_Store 等) - 根 .gitignore 加 unpackage/(uni-app 编译产物,唯一根未覆盖项) - vue-tsc 零错(移动不改代码,类型不变)
This commit is contained in:
279
apps/df-miniapp/src/api/ws.ts
Normal file
279
apps/df-miniapp/src/api/ws.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* df-relay WS 客户端(uni-app 跨端适配)
|
||||
*
|
||||
* 协议(对齐 crates/df-relay/src/relay.rs handle_connection):
|
||||
* 1. 建立 WS 连接到 `/ws/miniapp`
|
||||
* 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时)
|
||||
* 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close
|
||||
*
|
||||
* 跨端适配:
|
||||
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
|
||||
* - H5:uni.connectSocket 封装浏览器 WebSocket
|
||||
* - 统一经 uni API 封装,业务层不直接 new WebSocket
|
||||
*
|
||||
* 机制:
|
||||
* - 重连:指数退避(baseDelay * 2^n,封顶 maxDelay),握手失败/网络断/服务关闭触发
|
||||
* - 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
|
||||
* - JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
|
||||
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
|
||||
*/
|
||||
|
||||
import { getConfig } from '@/config'
|
||||
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
|
||||
|
||||
/** WS 连接状态 */
|
||||
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
|
||||
|
||||
/** 入站事件回调(Event/Control 消息分派) */
|
||||
export interface WsHandlers {
|
||||
/** 收到 Event 消息(payload 已解为 AiChatEvent JSON,业务层按 type discriminator 处理) */
|
||||
onEvent: (msg: BroadcastMessage) => void
|
||||
/** 收到 Control 消息(心跳响应 / 在线状态等) */
|
||||
onControl: (msg: ControlMessage) => void
|
||||
/** 状态变更(连接 / 断开 / 重连中) */
|
||||
onStatus: (status: WsStatus, detail?: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* WS 客户端单例(miniapp 全局一个连接)
|
||||
*
|
||||
* 设计:单 socket + 单 handlers 回调集合(composable 订阅,业务层不直接持 socket)。
|
||||
* 握手/心跳/重连机制内部封装,业务层仅关心 send(Command) + onEvent。
|
||||
*/
|
||||
class WsClient {
|
||||
private socket: UniApp.SocketTask | null = null
|
||||
private handlers: WsHandlers | null = null
|
||||
private status: WsStatus = 'disconnected'
|
||||
/** 握手是否已完成(收到首个广播或握手成功信号) */
|
||||
private handshaked = false
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempts = 0
|
||||
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
|
||||
private manualClose = false
|
||||
|
||||
/** 订阅(单订阅,composable 注册一次) */
|
||||
subscribe(h: WsHandlers): void {
|
||||
this.handlers = h
|
||||
}
|
||||
|
||||
/** 读取当前状态 */
|
||||
getStatus(): WsStatus {
|
||||
return this.status
|
||||
}
|
||||
|
||||
/** 主动连接 */
|
||||
connect(): void {
|
||||
if (this.status === 'connected' || this.status === 'connecting' || this.status === 'handshaking') {
|
||||
return
|
||||
}
|
||||
this.manualClose = false
|
||||
this.openSocket()
|
||||
}
|
||||
|
||||
/** 主动断开(用户手动,不重连) */
|
||||
disconnect(): void {
|
||||
this.manualClose = true
|
||||
this.cleanup()
|
||||
this.setStatus('disconnected', 'manual close')
|
||||
}
|
||||
|
||||
/** 若已断开则触发重连(onShow 时调用,小程序后台回前台) */
|
||||
resumeIfDisconnected(): void {
|
||||
if (this.status === 'disconnected' && !this.manualClose) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 Command(miniapp → device)
|
||||
*
|
||||
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
|
||||
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
|
||||
*/
|
||||
send(cmd: MiniCommand): boolean {
|
||||
if (!this.socket || !this.handshaked) {
|
||||
console.warn('[WsClient] 未连接或握手未完成,丢弃命令', cmd.cmd)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const text = JSON.stringify(cmd)
|
||||
this.socket.send({ data: text })
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('[WsClient] send 失败', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 内部实现 ──────────────────────────────────────────
|
||||
|
||||
private openSocket(): void {
|
||||
const cfg = getConfig()
|
||||
this.setStatus('connecting')
|
||||
console.log('[WsClient] 连接', cfg.relayHost)
|
||||
|
||||
try {
|
||||
this.socket = uni.connectSocket({
|
||||
url: cfg.relayHost,
|
||||
complete: () => {
|
||||
// complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[WsClient] connectSocket 异常', e)
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
this.socket.onOpen(() => this.handleOpen())
|
||||
this.socket.onMessage((res) => this.handleMessage(res))
|
||||
this.socket.onClose((res) => this.handleClose(res))
|
||||
this.socket.onError((res) => this.handleError(res))
|
||||
}
|
||||
|
||||
/** WS 已建立连接 → 立即发 Hello 握手帧 */
|
||||
private handleOpen(): void {
|
||||
this.setStatus('handshaking')
|
||||
const cfg = getConfig()
|
||||
const hello: Hello = {
|
||||
kind: 'miniapp',
|
||||
device_id: cfg.deviceId,
|
||||
token: cfg.token,
|
||||
}
|
||||
try {
|
||||
this.socket?.send({ data: JSON.stringify(hello) })
|
||||
} catch (e) {
|
||||
console.error('[WsClient] 发送 Hello 失败', e)
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
|
||||
private handleMessage(res: { data: string | ArrayBuffer }): void {
|
||||
const text = typeof res.data === 'string' ? res.data : ''
|
||||
if (!text) return
|
||||
|
||||
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
|
||||
if (!this.handshaked) {
|
||||
this.handleHandshakeResponse(text)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 BroadcastMessage(device 事件经 relay 包成完整 BroadcastMessage 下发)
|
||||
let msg: BroadcastMessage
|
||||
try {
|
||||
msg = JSON.parse(text) as BroadcastMessage
|
||||
} catch (e) {
|
||||
console.warn('[WsClient] 入站消息 JSON 解析失败', e, text.slice(0, 200))
|
||||
return
|
||||
}
|
||||
|
||||
// 按 kind 分派:Event → 业务层;Control → 心跳响应等
|
||||
if (msg.kind === 'event') {
|
||||
this.handlers?.onEvent(msg)
|
||||
} else if (msg.kind === 'control') {
|
||||
this.handlers?.onControl(msg.payload as ControlMessage)
|
||||
}
|
||||
// command 是 device→miniapp 反向(不应在此端收到,忽略)
|
||||
}
|
||||
|
||||
/** 处理握手响应(relay 握手通过后无显式 ack,首条业务消息即视为握手成功;
|
||||
* 失败时 relay 发 {"kind":"control","error":"..."} + 关连接) */
|
||||
private handleHandshakeResponse(text: string): void {
|
||||
// relay 握手失败帧(relay.rs:200-238):{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"}
|
||||
if (text.includes('"error"')) {
|
||||
console.error('[WsClient] 握手失败', text)
|
||||
this.setStatus('disconnected', `handshake failed: ${text}`)
|
||||
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
|
||||
this.cleanup()
|
||||
return
|
||||
}
|
||||
// 首条非错误消息即视为握手通过(实际握手后 relay 会路由 device 的 Event 过来)
|
||||
this.handshaked = true
|
||||
this.reconnectAttempts = 0
|
||||
this.setStatus('connected')
|
||||
this.startHeartbeat()
|
||||
// 该首消息也是业务消息,转 handleMessage 处理(此时 handshaked=true,走 BroadcastMessage 解析路径)
|
||||
this.handleMessage({ data: text })
|
||||
}
|
||||
|
||||
private handleClose(res: { code: number; reason: string }): void {
|
||||
console.log('[WsClient] 连接关闭', res.code, res.reason)
|
||||
this.cleanup()
|
||||
if (!this.manualClose) {
|
||||
this.scheduleReconnect()
|
||||
} else {
|
||||
this.setStatus('disconnected', 'manual close')
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(res: { errMsg: string }): void {
|
||||
console.error('[WsClient] socket 错误', res.errMsg)
|
||||
// 不直接 scheduleReconnect:onClose 会跟着触发,在 onClose 统一处理重连
|
||||
}
|
||||
|
||||
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'}) */
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
const cfg = getConfig()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
const ping: ControlMessage = { control_kind: 'ping' }
|
||||
// 直接发 ControlMessage JSON(relay 透传,device 可回 pong)
|
||||
try {
|
||||
this.socket?.send({ data: JSON.stringify(ping) })
|
||||
} catch (e) {
|
||||
console.warn('[WsClient] 心跳发送失败', e)
|
||||
}
|
||||
// 注:cfg.heartbeatInterval 读但不显式用(已通过 setInterval 传入),保 cfg 引用
|
||||
void cfg
|
||||
}, cfg.heartbeatInterval)
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 指数退避重连(baseDelay * 2^attempts,封顶 maxDelay) */
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) return
|
||||
const cfg = getConfig()
|
||||
const attempt = this.reconnectAttempts++
|
||||
const delay = Math.min(cfg.reconnectBaseDelay * Math.pow(2, attempt), cfg.reconnectMaxDelay)
|
||||
this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
|
||||
console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.openSocket()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** 清理 socket + 定时器(连接关闭时调) */
|
||||
private cleanup(): void {
|
||||
this.stopHeartbeat()
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.close({})
|
||||
} catch {
|
||||
// 已关闭的 socket close 抛错忽略
|
||||
}
|
||||
this.socket = null
|
||||
}
|
||||
this.handshaked = false
|
||||
}
|
||||
|
||||
private setStatus(status: WsStatus, detail?: string): void {
|
||||
this.status = status
|
||||
this.handlers?.onStatus(status, detail)
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局单例(miniapp 进程内一个连接) */
|
||||
export const wsClient = new WsClient()
|
||||
Reference in New Issue
Block a user