- ws.ts 心跳 pong 闭环 + useAiChat 看门狗治半连接挂死 - pages 新增 settings/ + chat/conversations 更新 + types events/relay + mdRenderer + tsconfig
601 lines
25 KiB
TypeScript
601 lines
25 KiB
TypeScript
/**
|
|
* 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
|
|
*
|
|
* 连接生命周期(P0/P1 审计后重构,根本性,非补丁):
|
|
* - 所有"建连"经 reconnectNow() 幂等入口:clear 退避 timer + cleanup + openSocket
|
|
* connect()/resumeIfDisconnected()/scheduleReconnect 回调全部经此,杜绝双连竞态 + socket 泄漏
|
|
* - openSocket() 幂等:首行 destroySocket 残留 socket,保证 this.socket 引用唯一
|
|
* - destroySocket(task):显式注销 onOpen/onMessage/onClose/onError(覆盖空函数,微信无 offXxx)
|
|
* + close,防旧 socket 闭包回调污染新连接(单例 this 句柄被多 socket 捕获)
|
|
* - cleanup() 彻底化:destroySocket + 停心跳 + 清所有 timer + 清 pendingQueue
|
|
* 清队列防跨连接/跨会话陈旧命令补发(旧命令上下文已失效)
|
|
* - 超时三重兜底:连接超时(onOpen 未到)/握手超时(hello_ack 丢失,>relay 10s)/
|
|
* onError 兜底(微信 socket onError 不保证 onClose 跟随,DNS/TLS/域名未加白场景)
|
|
* - 心跳活性检测:lastInboundAt + 2x 间隔判定半开死连接(NAT 超时/网络切换)
|
|
* - pendingQueue 上限 + TTL:防握手永卡时无限堆积内存泄漏
|
|
*
|
|
* 重连:指数退避(baseDelay * 2^min(n,6),封顶 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'
|
|
|
|
/** 调试日志门控(P1 走查:生产 console.log 过微信 bridge,热路径成本)。dev=true / build=false。 */
|
|
const DEBUG = import.meta.env.DEV
|
|
|
|
/** 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
|
|
}
|
|
|
|
/** pendingQueue 带入队时间戳,flush 时按 TTL 丢弃陈旧命令 */
|
|
interface QueuedCmd {
|
|
cmd: MiniCommand
|
|
ts: number
|
|
}
|
|
|
|
/** 握手超时(>relay.rs:321 服务端 10s,留余量) */
|
|
const HANDSHAKE_TIMEOUT_MS = 12000
|
|
/** 连接超时(openSocket 后 onOpen 未到则重连;治 onError 不触发 onClose 场景) */
|
|
const CONNECT_TIMEOUT_MS = 8000
|
|
/** pendingQueue 上限(防握手永卡时无限堆积内存泄漏) */
|
|
const PENDING_MAX = 50
|
|
/** pendingQueue 单条 TTL(flush 时超时丢弃,防跨连接陈旧命令补发) */
|
|
const PENDING_TTL_MS = 60000
|
|
/** 心跳活性判定因子:超过 N 倍心跳间隔无入站消息判半开死连接 */
|
|
const HEARTBEAT_DEAD_FACTOR = 2
|
|
/** 最大重连次数(永久性故障如 relayHost 错/中继下线,停止无限退避,提示用户检查) */
|
|
const MAX_RECONNECT_ATTEMPTS = 20
|
|
|
|
/**
|
|
* 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
|
|
/** 握手超时定时器(handleOpen 发 Hello 后启,收 hello_ack 后清) */
|
|
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
|
/** 连接超时定时器(openSocket 启,onOpen 到达后清) */
|
|
private connectTimer: ReturnType<typeof setTimeout> | null = null
|
|
private reconnectAttempts = 0
|
|
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
|
|
private manualClose = false
|
|
/** 可恢复标志:达上限 disconnected 时置 true,允许 connect/resumeIfDisconnected 重置重连 */
|
|
private recoverable = false
|
|
/**
|
|
* 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。
|
|
* 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪,
|
|
* 业务层快速重发会被 send 守卫放行后静默丢失;入队保证握手稳定后顺序补发。
|
|
* 带时间戳 + 上限 + TTL,防握手永卡时无限堆积(见 PENDING_MAX/PENDING_TTL_MS)。
|
|
*/
|
|
private pendingQueue: QueuedCmd[] = []
|
|
/** 最后入站消息时间戳(心跳活性检测,relay 无 pong 故用任意入站消息替代) */
|
|
private lastInboundAt = 0
|
|
|
|
/** 订阅(单订阅,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.recoverable = false
|
|
// 用户主动连接复位重连计数(可达上限后的 disconnected 也允许手动重试)
|
|
this.reconnectAttempts = 0
|
|
// 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连
|
|
this.reconnectNow()
|
|
}
|
|
|
|
/** 主动断开(用户手动,不重连) */
|
|
disconnect(): void {
|
|
this.manualClose = true
|
|
this.reconnectAttempts = 0
|
|
// cleanup 内部清 pendingQueue + 注销 socket + 清所有 timer
|
|
this.cleanup()
|
|
this.setStatus('disconnected', 'manual close')
|
|
}
|
|
|
|
/**
|
|
* 若已断开或正在退避重连则立即重连(onShow 时调用,小程序后台回前台)。
|
|
*
|
|
* 覆盖 `disconnected` 与 `reconnecting` 两种态:
|
|
* - disconnected:onClose 已触发,直接重连;
|
|
* - reconnecting:退避计时未到,抢占退避立即重连(治「切后台回前台卡在 reconnecting」头号症状)。
|
|
*
|
|
* 统一经 reconnectNow(幂等清理),保证抢占时不残留旧 socket/timer。
|
|
*/
|
|
resumeIfDisconnected(): void {
|
|
if (this.manualClose) return
|
|
// 已达上限 disconnected(recoverable=true)也允许重置计数并重连
|
|
if (this.status === 'disconnected' || this.status === 'reconnecting' || this.recoverable) {
|
|
this.recoverable = false
|
|
// 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复
|
|
this.reconnectAttempts = 0
|
|
this.reconnectNow()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 暂停后台定时器(小程序 onHide 调,P1-9):停心跳 setInterval。
|
|
*
|
|
* 微信后台定时器节流但仍触发,心跳持续发 ping 浪费 + 后台无入站致半开检测误判。
|
|
* 不 disconnect(disconnect 清队列 + manualClose,不适合后台暂离),仅停心跳保留连接。
|
|
* 重连 setTimeout 若后台断连触发,单次非循环,影响可接受(MVP 不暂停)。
|
|
*/
|
|
pauseBackground(): void {
|
|
this.stopHeartbeat()
|
|
}
|
|
|
|
/**
|
|
* 恢复前台(小程序 onShow 调):连接在则重启心跳,断则重连。
|
|
*/
|
|
resumeBackground(): void {
|
|
if (this.status === 'connected' && this.handshaked) {
|
|
this.startHeartbeat()
|
|
} else {
|
|
this.resumeIfDisconnected()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 发送 Command(miniapp → device)
|
|
*
|
|
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
|
|
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
|
|
*/
|
|
send(cmd: MiniCommand): boolean {
|
|
if (DEBUG) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked)
|
|
if (!this.socket || !this.handshaked) {
|
|
// 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。
|
|
// 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队,
|
|
// 否则(manualClose=true / 长期 disconnected)不堆积陈旧命令。
|
|
if (this.manualClose) {
|
|
console.warn('[WsClient] 已主动断开,丢弃命令', cmd.cmd)
|
|
return false
|
|
}
|
|
this.enqueuePending(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
|
|
}
|
|
}
|
|
|
|
/** 入队(带上限 + 时间戳,TTL 丢弃见 flushPendingQueue) */
|
|
private enqueuePending(cmd: MiniCommand): void {
|
|
if (this.pendingQueue.length >= PENDING_MAX) {
|
|
// 超上限丢弃最旧,防握手永卡时无限堆积内存泄漏
|
|
this.pendingQueue.shift()
|
|
console.warn('[WsClient] pendingQueue 超上限,丢弃最旧命令')
|
|
}
|
|
this.pendingQueue.push({ cmd, ts: Date.now() })
|
|
console.log('[WsClient] 握手未完成,命令入队待补发', cmd.cmd, 'queue=', this.pendingQueue.length)
|
|
}
|
|
|
|
/**
|
|
* 补发握手期间积压的命令队列(收到 hello_ack 后调)。
|
|
*
|
|
* list_conversations 已由握手逻辑自己发(不进队列),此处仅补发业务 send 积压。
|
|
* 补发前置 handshaked=true,send 守卫放行,逐条直发 socket。
|
|
* TTL 丢弃:入队超 PENDING_TTL_MS 的视为陈旧(上下文已失效),不补发。
|
|
*/
|
|
private flushPendingQueue(): void {
|
|
if (this.pendingQueue.length === 0) return
|
|
const now = Date.now()
|
|
console.log('[WsClient] 补发握手积压命令', this.pendingQueue.length)
|
|
while (this.pendingQueue.length > 0) {
|
|
const item = this.pendingQueue.shift()!
|
|
if (now - item.ts > PENDING_TTL_MS) {
|
|
console.warn('[WsClient] 补发时丢弃超时陈旧命令', item.cmd.cmd)
|
|
continue
|
|
}
|
|
try {
|
|
if (this.socket) {
|
|
this.socket.send({ data: JSON.stringify(item.cmd) })
|
|
}
|
|
} catch (e) {
|
|
console.error('[WsClient] 补发命令失败', item.cmd.cmd, e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 连接生命周期(统一入口 + 幂等清理) ───────────────────
|
|
|
|
/**
|
|
* 幂等立即建连入口(clear 退避 timer + cleanup + openSocket)。
|
|
*
|
|
* 所有需要"立即开新连接"的路径(connect/resumeIfDisconnected/scheduleReconnect 回调)
|
|
* 统一经此,保证:
|
|
* - 不残留挂起的 reconnectTimer(防退避 timer 与 openSocket 双触发建两个 socket);
|
|
* - 旧 socket 被 cleanup 注销+关闭(防 this.socket 引用覆盖后句柄泄漏);
|
|
* - pendingQueue 被清(防跨连接/跨会话陈旧命令补发)。
|
|
*/
|
|
private reconnectNow(): void {
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer)
|
|
this.reconnectTimer = null
|
|
}
|
|
this.cleanup()
|
|
this.openSocket()
|
|
}
|
|
|
|
private openSocket(): void {
|
|
// 幂等:残留 socket 先注销+关闭(reconnectNow 已 cleanup,此为双保险)
|
|
if (this.socket) {
|
|
this.destroySocket(this.socket)
|
|
this.socket = null
|
|
}
|
|
const cfg = getConfig()
|
|
this.setStatus('connecting')
|
|
console.log('[WsClient] 连接', cfg.relayHost)
|
|
|
|
let task: UniApp.SocketTask
|
|
try {
|
|
task = uni.connectSocket({
|
|
url: cfg.relayHost,
|
|
complete: () => {
|
|
// complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
|
|
},
|
|
})
|
|
} catch (e) {
|
|
console.error('[WsClient] connectSocket 异常', e)
|
|
this.scheduleReconnect()
|
|
return
|
|
}
|
|
this.socket = task
|
|
|
|
task.onOpen(() => this.handleOpen())
|
|
task.onMessage((res) => this.handleMessage(res))
|
|
task.onClose((res) => this.handleClose(res))
|
|
task.onError((res) => this.handleError(res))
|
|
|
|
// 连接超时:onOpen 未到则重连(治 onError 不触发 onClose 的场景:
|
|
// DNS 解析失败/TLS 握手失败/relayHost 不可达/wss 域名未加白名单)
|
|
this.connectTimer = setTimeout(() => {
|
|
this.connectTimer = null
|
|
if (this.status === 'connecting') {
|
|
console.warn('[WsClient] 连接超时,onOpen 未到,重连')
|
|
this.cleanup()
|
|
this.scheduleReconnect()
|
|
}
|
|
}, CONNECT_TIMEOUT_MS)
|
|
}
|
|
|
|
/** WS 已建立连接 → 立即发 Hello 握手帧 + 启握手超时 */
|
|
private handleOpen(): void {
|
|
// onOpen 到达,取消连接超时
|
|
if (this.connectTimer) {
|
|
clearTimeout(this.connectTimer)
|
|
this.connectTimer = null
|
|
}
|
|
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)
|
|
// 失败先 cleanup(关坏 socket + 清监听器)再退避,与 handleHandshakeResponse 失败路径对齐
|
|
this.cleanup()
|
|
this.scheduleReconnect()
|
|
return
|
|
}
|
|
// 握手超时:hello_ack 丢失(网络抖动/relay send_text 失败吞错 relay.rs:253-257)则重连。
|
|
// >relay.rs:321 服务端 10s,留余量。握手成功/失败时 handleHandshakeResponse 会清此 timer。
|
|
this.handshakeTimer = setTimeout(() => {
|
|
this.handshakeTimer = null
|
|
if (!this.handshaked) {
|
|
console.warn('[WsClient] 握手超时,hello_ack 未收到,重连')
|
|
this.cleanup()
|
|
this.scheduleReconnect()
|
|
}
|
|
}, HANDSHAKE_TIMEOUT_MS)
|
|
}
|
|
|
|
/** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
|
|
private handleMessage(res: { data: string | ArrayBuffer }): void {
|
|
// 协议纯 JSON 文本帧(relay 发文本),ArrayBuffer 非预期 —— 加 warn 可观测
|
|
// (防二进制帧静默丢弃致握手期 hello_ack 被吃却无日志)
|
|
if (typeof res.data !== 'string') {
|
|
console.warn('[WsClient] 收到非预期二进制帧,丢弃', (res.data as ArrayBuffer).byteLength)
|
|
return
|
|
}
|
|
const text = res.data
|
|
if (!text) return
|
|
// 活性:任意入站帧更新(含 relay 回的 pong 心跳响应,用于半连接检测)
|
|
this.lastInboundAt = Date.now()
|
|
|
|
// 握手阶段可能收到 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 发 hello_ack 显式 ack;失败时发 error 帧 + 关连接) */
|
|
private handleHandshakeResponse(text: string): void {
|
|
// 结构化解析(优先):对齐 relay.rs:255 实发 hello_ack 字面量
|
|
// 成功:{"kind":"control","payload":{"control_kind":"hello_ack"}}
|
|
// 失败:{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"} (relay.rs:200-238)
|
|
// 注:hello_ack 的 control_kind 在 payload 内(与 error 顶层字段结构不同,须分别判)。
|
|
let parsedOk = false
|
|
let parsed: { kind?: string; error?: string; payload?: { control_kind?: string } } | null = null
|
|
try {
|
|
parsed = JSON.parse(text) as { kind?: string; error?: string; payload?: { control_kind?: string } }
|
|
parsedOk = true
|
|
} catch (e) {
|
|
// JSON.parse 失败:降级回旧 includes 兜底,保证非 JSON 帧不误判
|
|
console.warn('[WsClient] 握手响应 JSON 解析失败,降级 includes 兜底', e, text.slice(0, 200))
|
|
}
|
|
|
|
if (parsedOk && parsed) {
|
|
// 显式 error 字段存在即握手失败(对齐 relay.rs:203/220/233 error 顶层字段)
|
|
if (parsed.error !== undefined) {
|
|
console.error('[WsClient] 握手失败', parsed.error, text)
|
|
this.setStatus('disconnected', `handshake failed: ${parsed.error}`)
|
|
this.clearHandshakeTimer()
|
|
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
|
|
this.cleanup()
|
|
return
|
|
}
|
|
// 显式 ack 信号:kind==='control' 且 payload.control_kind==='hello_ack'
|
|
// (对齐 relay.rs:255 实发字面量;ControlMessage 枚举未含 hello_ack 故不强转类型)
|
|
if (parsed.kind === 'control' && parsed.payload?.control_kind === 'hello_ack') {
|
|
this.clearHandshakeTimer()
|
|
this.handshaked = true
|
|
this.reconnectAttempts = 0
|
|
this.setStatus('connected')
|
|
this.startHeartbeat()
|
|
// F-#95:握手成功后立即发 list_conversations —— device 在线探测 + 首屏会话列表同步。
|
|
// device 在线则 route_list_conversations 推回 AiConversationList(useAiChat 置 deviceOnline=true);
|
|
// device 离线则无响应,文案保持「已连接中继」诚实表述。
|
|
// 此时 handshaked=true,send 守卫放行;relay 纯透传入站文本帧作 payload。
|
|
this.send({ cmd: 'list_conversations', args: {} })
|
|
// 补发握手期间积压的业务命令(治握手成功到首帧竞态丢命令)
|
|
this.flushPendingQueue()
|
|
// hello_ack 是纯控制帧无业务 payload,无需转 handleMessage 处理
|
|
return
|
|
}
|
|
// 解析成功但既非 error 也非 hello_ack:异常帧,丢弃等下一条(保守不误判握手通过)
|
|
console.warn('[WsClient] 握手阶段收到非 ack/error 控制帧,丢弃', text.slice(0, 200))
|
|
return
|
|
}
|
|
|
|
// 降级兜底(JSON.parse 失败走此分支):保留旧 includes 判定,防协议演进/非 JSON 帧
|
|
if (text.includes('"error"')) {
|
|
console.error('[WsClient] 握手失败(includes 兜底)', text)
|
|
this.setStatus('disconnected', `handshake failed: ${text}`)
|
|
this.clearHandshakeTimer()
|
|
this.cleanup()
|
|
return
|
|
}
|
|
// 兜底:非错误消息视为握手通过(覆盖旧实现行为,防解析失败的 hello_ack 帧)
|
|
this.clearHandshakeTimer()
|
|
this.handshaked = true
|
|
this.reconnectAttempts = 0
|
|
this.setStatus('connected')
|
|
this.startHeartbeat()
|
|
this.send({ cmd: 'list_conversations', args: {} })
|
|
// 补发握手期间积压的业务命令(降级兜底路径同样补发)
|
|
this.flushPendingQueue()
|
|
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)
|
|
// 微信 socket onError 不保证 onClose 跟随(DNS/TLS 失败/域名未加白名单/onOpen 也不触发)。
|
|
// 若尚在 connecting/handshaking(未进过 connected),主动兜底重连,防永卡。
|
|
// connected 态的半开死连接由心跳活性检测(P1-2)兜底,此处不重连(可能 onClose 随后跟随)。
|
|
if (this.status === 'connecting' || this.status === 'handshaking') {
|
|
this.cleanup()
|
|
this.scheduleReconnect()
|
|
}
|
|
}
|
|
|
|
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'} + 半开死连接检测) */
|
|
private startHeartbeat(): void {
|
|
this.stopHeartbeat()
|
|
const cfg = getConfig()
|
|
// 连接成功初始化活性时间(首次心跳检查不立即判死)
|
|
this.lastInboundAt = Date.now()
|
|
this.heartbeatTimer = setInterval(() => {
|
|
// 死连接检测:NAT 超时/网络切换致 TCP 半开(onClose 不触发,socket 仍 open)。
|
|
// relay 无 pong,用「任意入站消息」更新 lastInboundAt;超过 N 倍间隔无入站判死,主动重连。
|
|
const now = Date.now()
|
|
if (now - this.lastInboundAt > cfg.heartbeatInterval * HEARTBEAT_DEAD_FACTOR) {
|
|
console.warn('[WsClient] 心跳活性检测:连接疑似半开死连接,主动重连')
|
|
this.cleanup()
|
|
this.scheduleReconnect()
|
|
return
|
|
}
|
|
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^min(attempt,6),封顶 maxDelay,带随机抖动)。
|
|
*
|
|
* - attempt 软上限 6:超 6 不再翻倍,防长尾退避爬到 maxDelay 后卡死;
|
|
* - 抖动因子 0.5~1.0:防多客户端重连风暴(雷同退避同步触发);
|
|
* - manualClose/disconnect 时复位 reconnectAttempts(见 disconnect)。
|
|
*
|
|
* 回调统一经 reconnectNow(幂等清理 + openSocket),不裸调 openSocket。
|
|
*/
|
|
private scheduleReconnect(): void {
|
|
if (this.reconnectTimer) return
|
|
// 永久性故障上限:relayHost 错/中继下线时停止无限退避,避免长跑空耗 + 误导用户「重连中」。
|
|
// 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。
|
|
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`)
|
|
// 不清 cleanup(保留 manualClose 不变),仅设状态 + 停退避 timer
|
|
// 让 connect()/resumeIfDisconnected() 可重置计数后恢复
|
|
this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连')
|
|
this.recoverable = true
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer)
|
|
this.reconnectTimer = null
|
|
}
|
|
return
|
|
}
|
|
const cfg = getConfig()
|
|
const attempt = this.reconnectAttempts++
|
|
const exp = Math.min(attempt, 6)
|
|
const base = Math.min(cfg.reconnectBaseDelay * Math.pow(2, exp), cfg.reconnectMaxDelay)
|
|
const jitter = 0.5 + Math.random() * 0.5
|
|
const delay = Math.round(base * jitter)
|
|
this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
|
|
console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null
|
|
this.reconnectNow()
|
|
}, delay)
|
|
}
|
|
|
|
/** 清握手超时定时器(握手成功/失败时调) */
|
|
private clearHandshakeTimer(): void {
|
|
if (this.handshakeTimer) {
|
|
clearTimeout(this.handshakeTimer)
|
|
this.handshakeTimer = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 注销并关闭指定 socket task(防旧 socket 闭包回调污染新连接)。
|
|
*
|
|
* 微信 SocketTask 无 offXxx API,用「覆盖空函数」解绑:onXxx 多次调用覆盖最后者。
|
|
* 必须对传入 task 操作(非 this.socket),避免 this.socket 时序指向新 socket 时误注销。
|
|
*/
|
|
private destroySocket(task: UniApp.SocketTask): void {
|
|
try { task.onOpen(() => {}) } catch { /* 注销忽略 */ }
|
|
try { task.onMessage(() => {}) } catch { /* 注销忽略 */ }
|
|
try { task.onClose(() => {}) } catch { /* 注销忽略 */ }
|
|
try { task.onError(() => {}) } catch { /* 注销忽略 */ }
|
|
try {
|
|
task.close({})
|
|
} catch {
|
|
// 已关闭的 socket close 抛错忽略
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 彻底清理:停心跳 + 清所有 timer + 注销关闭 socket + 清队列 + 复位 handshaked。
|
|
*
|
|
* 清 pendingQueue:断连/重连/抢占时,旧命令上下文已失效,不补发到新连接(防串话)。
|
|
*/
|
|
private cleanup(): void {
|
|
this.stopHeartbeat()
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer)
|
|
this.reconnectTimer = null
|
|
}
|
|
this.clearHandshakeTimer()
|
|
if (this.connectTimer) {
|
|
clearTimeout(this.connectTimer)
|
|
this.connectTimer = null
|
|
}
|
|
if (this.socket) {
|
|
this.destroySocket(this.socket)
|
|
this.socket = null
|
|
}
|
|
this.handshaked = false
|
|
this.pendingQueue = []
|
|
}
|
|
|
|
private setStatus(status: WsStatus, detail?: string): void {
|
|
this.status = status
|
|
this.handlers?.onStatus(status, detail)
|
|
}
|
|
}
|
|
|
|
/** 全局单例(miniapp 进程内一个连接) */
|
|
export const wsClient = new WsClient()
|