425 lines
11 KiB
Plaintext
425 lines
11 KiB
Plaintext
|
|
import {
|
|
IMessageBase,
|
|
IConnectOptions,
|
|
IWebSocketCallbacks,
|
|
IMessageQueueItem,
|
|
IChatMessage,
|
|
IHeartbeatMessage,
|
|
IWebSocketPayload
|
|
} from './socket-types.uts'
|
|
|
|
import userState from '@/stores/user.uts'
|
|
|
|
class WebSocketManager {
|
|
private static instance : WebSocketManager | null = null
|
|
private socketTask : SocketTask | null = null
|
|
private url = ''
|
|
private protocols : Array<string> = []
|
|
private header : UTSJSONObject | null = null
|
|
private connected = false
|
|
private connecting = false
|
|
private manualClose = false
|
|
private reconnectTimer : number | null = null
|
|
private heartbeatTimer : number | null = null
|
|
private lastHeartbeatAt = 0
|
|
private waitingHeartbeat = false
|
|
private reconnectCount = 0
|
|
private reconnectInterval = 3000
|
|
private heartbeatInterval = 20000
|
|
private heartbeatTimeout = 10000
|
|
private connectTimeout = 10000
|
|
private maxReconnectCount = 5
|
|
private debug = false
|
|
private callbacks : IWebSocketCallbacks = {}
|
|
private messageQueue : Array<IMessageQueueItem> = []
|
|
|
|
static getInstance() : WebSocketManager {
|
|
if (WebSocketManager.instance == null) {
|
|
WebSocketManager.instance = new WebSocketManager()
|
|
}
|
|
return WebSocketManager.instance
|
|
}
|
|
|
|
connect(url : string, options : IConnectOptions | null = null) : void {
|
|
this.log('WebSocket connect')
|
|
if (url.length == 0) {
|
|
this.log('WebSocket url 不能为空')
|
|
return
|
|
}
|
|
const urlChanged = this.url.length > 0 && this.url != url
|
|
this.url = url
|
|
this.applyOptions(options)
|
|
if (urlChanged == true) {
|
|
this.releaseSocket()
|
|
}
|
|
if (this.connected == true || this.connecting == true) {
|
|
return
|
|
}
|
|
this.manualClose = false
|
|
this.connecting = true
|
|
this.clearReconnectTimer()
|
|
this.createSocketTask()
|
|
}
|
|
|
|
/**
|
|
* 应用配置
|
|
*/
|
|
private applyOptions(options : IConnectOptions | null) : void {
|
|
if (options == null) {
|
|
return
|
|
}
|
|
if (options.reconnectInterval != null) this.reconnectInterval = options.reconnectInterval
|
|
if (options.heartbeatInterval != null) this.heartbeatInterval = options.heartbeatInterval
|
|
if (options.maxReconnectCount != null) this.maxReconnectCount = options.maxReconnectCount
|
|
if (options.protocols != null) this.protocols = options.protocols
|
|
if (options.header != null) this.header = options.header
|
|
if (options.heartbeatTimeout != null) this.heartbeatTimeout = options.heartbeatTimeout
|
|
if (options.connectTimeout != null) this.connectTimeout = options.connectTimeout
|
|
if (options.debug != null) this.debug = options.debug
|
|
}
|
|
|
|
private createSocketTask() : void {
|
|
this.releaseSocket()
|
|
let finished = false
|
|
const finishConnect = () : void => {
|
|
if (finished == true) {
|
|
return
|
|
}
|
|
finished = true
|
|
this.connecting = false
|
|
}
|
|
|
|
const connectTimeoutTimer = setTimeout(() => {
|
|
finishConnect()
|
|
this.handleError({ errMsg: 'WebSocket 连接超时' })
|
|
this.scheduleReconnect('connect timeout')
|
|
}, this.connectTimeout) as number
|
|
|
|
try {
|
|
this.socketTask = uni.connectSocket({
|
|
url: this.url,
|
|
header: this.header,
|
|
protocols: this.protocols
|
|
})
|
|
|
|
const currentTask = this.socketTask
|
|
if (currentTask == null) {
|
|
clearTimeout(connectTimeoutTimer)
|
|
finishConnect()
|
|
return
|
|
}
|
|
|
|
currentTask.onOpen((res : any) => {
|
|
clearTimeout(connectTimeoutTimer)
|
|
finishConnect()
|
|
this.connected = true
|
|
this.reconnectCount = 0
|
|
this.waitingHeartbeat = false
|
|
this.startHeartbeat()
|
|
this.flushMessageQueue()
|
|
if (this.callbacks.onOpen != null) {
|
|
this.callbacks.onOpen(res)
|
|
}
|
|
uni.$emit('onWsOpen', res)
|
|
})
|
|
|
|
currentTask.onMessage((res : OnSocketMessageCallbackResult) => {
|
|
console.log(res)
|
|
this.handleMessage(res)
|
|
})
|
|
|
|
currentTask.onError((err : any) => {
|
|
clearTimeout(connectTimeoutTimer)
|
|
finishConnect()
|
|
uni.$emit('onWsError', err)
|
|
this.handleError(err)
|
|
})
|
|
|
|
currentTask.onClose((res : any) => {
|
|
clearTimeout(connectTimeoutTimer)
|
|
finishConnect()
|
|
uni.$emit('onWsClose', res)
|
|
this.handleClose(res)
|
|
})
|
|
} catch (error) {
|
|
clearTimeout(connectTimeoutTimer)
|
|
finishConnect()
|
|
this.handleError(error)
|
|
}
|
|
}
|
|
|
|
private handleMessage(res : OnSocketMessageCallbackResult) : void {
|
|
const dataStr = res.data as string
|
|
// #ifndef APP
|
|
const rawData = JSON.parse(dataStr) as IMessageBase
|
|
// #endif
|
|
// #ifdef APP
|
|
const rawJson = JSON.parseObject(dataStr)
|
|
console.log("OnMessage:", rawJson)
|
|
const rawData = rawJson?.parse<IMessageBase>()
|
|
// #endif
|
|
//if (rawData.type == "ping")
|
|
console.log("OnMessage:", rawData)
|
|
if (rawData != null && rawData.header.type == "pong") {
|
|
this.waitingHeartbeat = false
|
|
this.lastHeartbeatAt = Date.now()
|
|
return
|
|
}
|
|
if (rawData != null && this.callbacks.onMessage != null) {
|
|
uni.$emit('onWsMessage', rawData)
|
|
this.callbacks.onMessage(rawData)
|
|
}
|
|
}
|
|
|
|
send(data : IWebSocketPayload, success : (() => void) | null = null, fail : ((err : any) => void) | null = null) : void {
|
|
let payload : string | IMessageBase | UTSJSONObject | null = null
|
|
try {
|
|
payload = JSON.stringify(data)
|
|
} catch (error) {
|
|
payload = null
|
|
}
|
|
if (payload == null) {
|
|
if (fail != null) {
|
|
fail({ errMsg: 'WebSocket 消息序列化失败' })
|
|
}
|
|
return
|
|
}
|
|
if (this.socketTask == null || this.connected == false) {
|
|
this.enqueueMessage(data, success, fail)
|
|
return
|
|
}
|
|
this.socketTask.send({
|
|
data: payload,
|
|
success: () => {
|
|
if (success != null) {
|
|
success()
|
|
}
|
|
},
|
|
fail: (err : any) => {
|
|
this.enqueueMessage(data, success, fail)
|
|
if (fail != null) {
|
|
fail(err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
close(code : number = 1000, reason : string = 'manual close') : void {
|
|
this.manualClose = true
|
|
this.stopHeartbeat()
|
|
this.clearReconnectTimer()
|
|
this.connected = false
|
|
this.connecting = false
|
|
if (this.socketTask != null) {
|
|
try {
|
|
this.socketTask.close({ code, reason })
|
|
} catch (error) {
|
|
this.log('关闭 WebSocket 失败', error)
|
|
}
|
|
}
|
|
this.releaseSocket()
|
|
}
|
|
|
|
on(callbacks : IWebSocketCallbacks) : void {
|
|
if (callbacks.onOpen != null) this.callbacks.onOpen = callbacks.onOpen
|
|
if (callbacks.onMessage != null) this.callbacks.onMessage = callbacks.onMessage
|
|
if (callbacks.onError != null) this.callbacks.onError = callbacks.onError
|
|
if (callbacks.onClose != null) this.callbacks.onClose = callbacks.onClose
|
|
if (callbacks.onReconnect != null) this.callbacks.onReconnect = callbacks.onReconnect
|
|
if (callbacks.onReconnectFailed != null) this.callbacks.onReconnectFailed = callbacks.onReconnectFailed
|
|
if (callbacks.onHeartbeatTimeout != null) this.callbacks.onHeartbeatTimeout = callbacks.onHeartbeatTimeout
|
|
}
|
|
|
|
isConnected() : boolean {
|
|
return this.connected
|
|
}
|
|
|
|
getReconnectCount() : number {
|
|
return this.reconnectCount
|
|
}
|
|
|
|
getStats() : UTSJSONObject {
|
|
return {
|
|
url: this.url,
|
|
connected: this.connected,
|
|
connecting: this.connecting,
|
|
reconnectCount: this.reconnectCount,
|
|
queueSize: this.messageQueue.length,
|
|
lastHeartbeatAt: this.lastHeartbeatAt
|
|
} as UTSJSONObject
|
|
}
|
|
|
|
removeAllListeners() : void {
|
|
this.callbacks = {}
|
|
}
|
|
|
|
/**
|
|
* 开始心跳
|
|
*/
|
|
private startHeartbeat() : void {
|
|
this.stopHeartbeat()
|
|
if (this.heartbeatInterval <= 0) return
|
|
this.heartbeatTimer = setInterval(() => {
|
|
if (this.connected == false) {
|
|
this.stopHeartbeat()
|
|
return
|
|
}
|
|
if (this.waitingHeartbeat == true) {
|
|
const expired = Date.now() - this.lastHeartbeatAt > this.heartbeatTimeout
|
|
if (expired == true) {
|
|
if (this.callbacks.onHeartbeatTimeout != null) {
|
|
this.callbacks.onHeartbeatTimeout({ errMsg: 'WebSocket 心跳超时' })
|
|
}
|
|
this.scheduleReconnect('heartbeat timeout')
|
|
return
|
|
}
|
|
}
|
|
this.waitingHeartbeat = true
|
|
this.lastHeartbeatAt = Date.now()
|
|
const heartbeatUserId = userState.uid
|
|
const heartData : IMessageBase = {
|
|
header: {
|
|
id: "heartbeat_1700000000000",
|
|
type: "ping",
|
|
seqno: 1,
|
|
timestamp: Date.now(),
|
|
source: "client",
|
|
target: "system",
|
|
version: "1.0.0",
|
|
platform: 'android',
|
|
device_id: "device_id",
|
|
network_type: 'wifi',
|
|
},
|
|
payload: {
|
|
uid: heartbeatUserId,
|
|
status: 'sent',
|
|
type: 'text',
|
|
content: 'ping'
|
|
}
|
|
}
|
|
this.send(heartData)
|
|
}, this.heartbeatInterval) as number
|
|
}
|
|
|
|
/**
|
|
* 停止心跳
|
|
*/
|
|
private stopHeartbeat() : void {
|
|
const timer = this.heartbeatTimer
|
|
if (timer != null) {
|
|
clearInterval(timer)
|
|
this.heartbeatTimer = null
|
|
}
|
|
this.waitingHeartbeat = false
|
|
}
|
|
|
|
/**
|
|
* 将消息入队
|
|
* @param data IWebSocketPayload
|
|
* @param success (() => void) | null
|
|
* @param fail fail : ((err : any) => void) | null
|
|
* @return { void }
|
|
*/
|
|
private enqueueMessage(data : IWebSocketPayload, success : (() => void) | null, fail : ((err : any) => void) | null) : void {
|
|
this.messageQueue.push({ data, success, fail })
|
|
if (this.messageQueue.length > 100) {
|
|
this.messageQueue.splice(0, this.messageQueue.length - 100)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 刷新消息队列
|
|
* @return {void}
|
|
*/
|
|
private flushMessageQueue() : void {
|
|
if (this.messageQueue.length == 0) {
|
|
return
|
|
}
|
|
|
|
const queue = this.messageQueue.slice()
|
|
this.messageQueue = []
|
|
for (let i = 0; i < queue.length; i++) {
|
|
const item = queue[i]
|
|
this.send(item.data, item.success, item.fail)
|
|
}
|
|
}
|
|
|
|
private handleError(err : any) : void {
|
|
if (this.callbacks.onError != null) {
|
|
this.callbacks.onError(err)
|
|
}
|
|
this.scheduleReconnect('socket error')
|
|
}
|
|
|
|
private handleClose(res : any) : void {
|
|
this.connected = false
|
|
this.connecting = false
|
|
this.stopHeartbeat()
|
|
this.releaseSocket()
|
|
if (this.callbacks.onClose != null) {
|
|
this.callbacks.onClose(res)
|
|
}
|
|
if (this.manualClose == false) {
|
|
this.scheduleReconnect('socket close')
|
|
}
|
|
}
|
|
|
|
private scheduleReconnect(reason : string) : void {
|
|
if (this.manualClose == true || this.url.length == 0) {
|
|
return
|
|
}
|
|
if (this.reconnectTimer != null) {
|
|
return
|
|
}
|
|
if (this.reconnectCount >= this.maxReconnectCount) {
|
|
if (this.callbacks.onReconnectFailed != null) {
|
|
this.callbacks.onReconnectFailed({ errMsg: reason, reconnectCount: this.reconnectCount })
|
|
}
|
|
return
|
|
}
|
|
this.connected = false
|
|
this.connecting = false
|
|
this.stopHeartbeat()
|
|
this.releaseSocket()
|
|
this.reconnectCount = this.reconnectCount + 1
|
|
if (this.callbacks.onReconnect != null) {
|
|
this.callbacks.onReconnect({ reason, reconnectCount: this.reconnectCount })
|
|
}
|
|
const delay = this.reconnectInterval * this.reconnectCount
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null
|
|
this.connect(this.url)
|
|
}, delay) as number
|
|
}
|
|
|
|
private clearReconnectTimer() : void {
|
|
const timer = this.reconnectTimer
|
|
if (timer != null) {
|
|
clearTimeout(timer)
|
|
this.reconnectTimer = null
|
|
}
|
|
}
|
|
|
|
private releaseSocket() : void {
|
|
this.socketTask = null
|
|
}
|
|
|
|
private log(message : string, detail : any | null = null) : void {
|
|
if (this.debug == false) {
|
|
return
|
|
}
|
|
|
|
if (detail != null) {
|
|
console.log('[WebSocketManager]', message, detail)
|
|
return
|
|
}
|
|
|
|
console.log('[WebSocketManager]', message)
|
|
}
|
|
}
|
|
|
|
export type { IConnectOptions, IWebSocketCallbacks }
|
|
export const wsManager = WebSocketManager.getInstance()
|
|
export default WebSocketManager
|