websocket.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. import env from '../config/env.js'
  2. const HEARTBEAT_CMD = { cmd: 1 }
  3. function safeJsonParse(data) {
  4. if (typeof data !== 'string') return data
  5. try {
  6. return JSON.parse(data)
  7. } catch (error) {
  8. return data
  9. }
  10. }
  11. function getHostFromBaseUrl(baseUrl) {
  12. return String(baseUrl || '')
  13. .replace(/^https?:\/\//, '')
  14. .replace(/\/+$/, '')
  15. }
  16. function getShortStack() {
  17. try {
  18. const stack = new Error().stack || ''
  19. return stack.split('\n').slice(2, 8).join('\n')
  20. } catch (error) {
  21. return ''
  22. }
  23. }
  24. class WebSocketService {
  25. constructor() {
  26. this.socketTask = null
  27. this.isConnected = false
  28. this.isManualClose = false
  29. this.connectingPromise = null
  30. this.connectOptions = null
  31. this.reconnectAttempts = 0
  32. this.maxReconnectAttempts = 5
  33. this.reconnectInterval = 3000
  34. this.reconnectTimer = null
  35. this.heartbeatInterval = 20000
  36. this.heartbeatTimer = null
  37. this.listeners = {}
  38. this.useGlobalSocketApi = false
  39. this.globalSocketHandlers = null
  40. }
  41. on(eventName, handler) {
  42. if (!eventName || typeof handler !== 'function') return () => {}
  43. if (!this.listeners[eventName]) this.listeners[eventName] = new Set()
  44. this.listeners[eventName].add(handler)
  45. return () => this.off(eventName, handler)
  46. }
  47. off(eventName, handler) {
  48. if (!eventName || !this.listeners[eventName]) return
  49. if (!handler) {
  50. this.listeners[eventName].clear()
  51. return
  52. }
  53. this.listeners[eventName].delete(handler)
  54. }
  55. emit(eventName, payload) {
  56. const group = this.listeners[eventName]
  57. if (!group || !group.size) return
  58. group.forEach((fn) => {
  59. try {
  60. fn(payload)
  61. } catch (error) {
  62. console.error(`[WebSocket] listener error for ${eventName}:`, error)
  63. }
  64. })
  65. }
  66. buildSocketUrl(options = {}) {
  67. const role = options.role || 'parent'
  68. // const base = 'wss://yx.newfeifan.cn/btcSkt'
  69. // 开发地址(保留注释):const base = 'ws://192.168.220.13:8080/btcSkt'
  70. const base = 'wss://m.hfdcschool.com/btcSkt'
  71. // 原 env 写法(保留注释):const base = `wss://${getHostFromBaseUrl(env.baseUrl)}/btcSkt`
  72. if (role === 'device') {
  73. const ssDevId = options.ssDevId || ''
  74. if (!ssDevId) throw new Error('缺少 ssDevId')
  75. return `${base}?ssDevId=${encodeURIComponent(ssDevId)}`
  76. }
  77. const ssToken = options.ssToken || ''
  78. if (!ssToken) throw new Error('缺少 ssToken')
  79. return `${base}?ssToken=${encodeURIComponent(ssToken)}`
  80. }
  81. async connect(options = {}) {
  82. console.log('[WebSocket] connect called', {
  83. isConnected: this.isConnected,
  84. hasSocketTask: !!this.socketTask,
  85. hasConnectingPromise: !!this.connectingPromise,
  86. options,
  87. stack: getShortStack()
  88. })
  89. if (this.isConnected && this.socketTask && !options.forceReconnect) {
  90. console.log('[WebSocket] connect skip: already connected')
  91. return true
  92. }
  93. if (this.connectingPromise) {
  94. console.log('[WebSocket] connect reuse: existing connectingPromise')
  95. return this.connectingPromise
  96. }
  97. if (options.forceReconnect) {
  98. await this.disconnect()
  99. }
  100. if (this.socketTask && !this.isConnected && !options.forceReconnect) {
  101. console.warn('[WebSocket] found stale socketTask while disconnected, cleanup before reconnect')
  102. await this.disconnect()
  103. }
  104. const merged = {
  105. role: options.role || this.connectOptions?.role || 'parent',
  106. ssToken: options.ssToken || this.connectOptions?.ssToken || '',
  107. ssDevId: options.ssDevId || this.connectOptions?.ssDevId || '',
  108. heartbeat: options.heartbeat ?? this.connectOptions?.heartbeat ?? false,
  109. heartbeatInterval: options.heartbeatInterval || this.connectOptions?.heartbeatInterval || this.heartbeatInterval,
  110. autoReconnect: options.autoReconnect ?? this.connectOptions?.autoReconnect ?? true,
  111. maxReconnectAttempts: options.maxReconnectAttempts || this.connectOptions?.maxReconnectAttempts || this.maxReconnectAttempts,
  112. reconnectInterval: options.reconnectInterval || this.connectOptions?.reconnectInterval || this.reconnectInterval
  113. }
  114. this.connectOptions = merged
  115. this.heartbeatInterval = merged.heartbeatInterval
  116. this.maxReconnectAttempts = merged.maxReconnectAttempts
  117. this.reconnectInterval = merged.reconnectInterval
  118. this.isManualClose = false
  119. const wsUrl = this.buildSocketUrl(merged)
  120. console.log('[WebSocket] connecting...', {
  121. wsUrl,
  122. merged
  123. })
  124. const socketApi = typeof wx !== 'undefined' ? wx : uni
  125. this.connectingPromise = new Promise((resolve, reject) => {
  126. let settled = false
  127. let openTimeout = null
  128. const resolveOnce = (value) => {
  129. if (settled) return
  130. settled = true
  131. resolve(value)
  132. }
  133. const rejectOnce = (error) => {
  134. if (settled) return
  135. settled = true
  136. reject(error)
  137. }
  138. const handleOpen = () => {
  139. if (openTimeout) clearTimeout(openTimeout)
  140. this.isConnected = true
  141. this.reconnectAttempts = 0
  142. this.connectingPromise = null
  143. if (merged.heartbeat) {
  144. this.startHeartbeat()
  145. } else {
  146. this.stopHeartbeat()
  147. }
  148. this.emit('open', { url: wsUrl, options: merged })
  149. console.log('[WebSocket] open:', wsUrl)
  150. resolveOnce(true)
  151. }
  152. const handleMessage = (res) => {
  153. const data = safeJsonParse(res?.data)
  154. this.emit('message', data)
  155. if (data && typeof data === 'object' && data.cmd !== undefined) {
  156. this.emit(`cmd:${data.cmd}`, data)
  157. if (data.cmd === 51) this.emit('refresh', data)
  158. }
  159. }
  160. const handleError = (error) => {
  161. console.error('[WebSocket] error:', {
  162. wsUrl,
  163. error
  164. })
  165. this.emit('error', error)
  166. if (!this.isConnected && this.connectingPromise) {
  167. this.connectingPromise = null
  168. rejectOnce(error)
  169. }
  170. }
  171. const handleClose = (res) => {
  172. console.log('[WebSocket] close:', {
  173. wsUrl,
  174. isManualClose: this.isManualClose,
  175. reconnectEnabled: !!this.connectOptions?.autoReconnect,
  176. reconnectAttempts: this.reconnectAttempts,
  177. closeEvent: res
  178. })
  179. this.isConnected = false
  180. this.stopHeartbeat()
  181. this.emit('close', res)
  182. if (!this.isManualClose && this.connectOptions?.autoReconnect) {
  183. this.tryReconnect()
  184. }
  185. }
  186. let task = null
  187. try {
  188. task = socketApi.connectSocket({ url: wsUrl })
  189. } catch (error) {
  190. console.error('[WebSocket] connectSocket throw:', { wsUrl, error })
  191. this.connectingPromise = null
  192. rejectOnce(error)
  193. return
  194. }
  195. if (task && typeof task.onOpen === 'function') {
  196. this.useGlobalSocketApi = false
  197. this.socketTask = task
  198. task.onOpen(handleOpen)
  199. task.onMessage(handleMessage)
  200. task.onError(handleError)
  201. task.onClose(handleClose)
  202. console.log('[WebSocket] using task-level socket handlers')
  203. return
  204. }
  205. if (
  206. typeof socketApi.onSocketOpen !== 'function' ||
  207. typeof socketApi.onSocketMessage !== 'function' ||
  208. typeof socketApi.onSocketError !== 'function' ||
  209. typeof socketApi.onSocketClose !== 'function'
  210. ) {
  211. this.connectingPromise = null
  212. rejectOnce(new Error('创建 WebSocket 失败'))
  213. return
  214. }
  215. this.useGlobalSocketApi = true
  216. this.socketTask = { __global: true }
  217. const openHandler = () => handleOpen()
  218. const messageHandler = (res) => handleMessage(res)
  219. const errorHandler = (err) => handleError(err)
  220. const closeHandler = (res) => handleClose(res)
  221. this.globalSocketHandlers = { openHandler, messageHandler, errorHandler, closeHandler }
  222. socketApi.onSocketOpen(openHandler)
  223. socketApi.onSocketMessage(messageHandler)
  224. socketApi.onSocketError(errorHandler)
  225. socketApi.onSocketClose(closeHandler)
  226. console.log('[WebSocket] using global socket handlers')
  227. openTimeout = setTimeout(() => {
  228. this.connectingPromise = null
  229. rejectOnce(new Error('WebSocket 连接超时(未收到 onSocketOpen)'))
  230. }, 15000)
  231. })
  232. return this.connectingPromise
  233. }
  234. tryReconnect() {
  235. if (this.reconnectTimer) return
  236. if (this.reconnectAttempts >= this.maxReconnectAttempts) return
  237. this.reconnectAttempts += 1
  238. console.warn('[WebSocket] schedule reconnect', {
  239. reconnectAttempts: this.reconnectAttempts,
  240. maxReconnectAttempts: this.maxReconnectAttempts,
  241. reconnectInterval: this.reconnectInterval,
  242. connectOptions: this.connectOptions
  243. })
  244. this.reconnectTimer = setTimeout(async () => {
  245. this.reconnectTimer = null
  246. try {
  247. console.warn('[WebSocket] reconnecting now', {
  248. reconnectAttempts: this.reconnectAttempts
  249. })
  250. await this.connect(this.connectOptions || {})
  251. } catch (error) {
  252. console.error('[WebSocket] reconnect failed', error)
  253. this.tryReconnect()
  254. }
  255. }, this.reconnectInterval)
  256. }
  257. startHeartbeat() {
  258. this.stopHeartbeat()
  259. console.log('[WebSocket] heartbeat start', { heartbeatInterval: this.heartbeatInterval })
  260. this.heartbeatTimer = setInterval(() => {
  261. if (!this.isConnected) return
  262. console.log('[WebSocket] heartbeat send', HEARTBEAT_CMD)
  263. this.send(HEARTBEAT_CMD).catch(() => {})
  264. }, this.heartbeatInterval)
  265. }
  266. stopHeartbeat() {
  267. if (!this.heartbeatTimer) return
  268. console.log('[WebSocket] heartbeat stop', {
  269. isConnected: this.isConnected,
  270. stack: getShortStack()
  271. })
  272. clearInterval(this.heartbeatTimer)
  273. this.heartbeatTimer = null
  274. }
  275. async disconnect() {
  276. console.log('[WebSocket] disconnect called', {
  277. hasSocketTask: !!this.socketTask,
  278. useGlobalSocketApi: this.useGlobalSocketApi
  279. })
  280. this.isManualClose = true
  281. this.stopHeartbeat()
  282. if (this.reconnectTimer) {
  283. clearTimeout(this.reconnectTimer)
  284. this.reconnectTimer = null
  285. }
  286. const socketApi = typeof wx !== 'undefined' ? wx : uni
  287. return new Promise((resolve) => {
  288. if (!this.socketTask) {
  289. this.socketTask = null
  290. this.useGlobalSocketApi = false
  291. this.isConnected = false
  292. this.connectingPromise = null
  293. resolve()
  294. return
  295. }
  296. if (this.useGlobalSocketApi || this.socketTask.__global) {
  297. if (this.globalSocketHandlers) {
  298. const { openHandler, messageHandler, errorHandler, closeHandler } = this.globalSocketHandlers
  299. if (typeof socketApi.offSocketOpen === 'function') socketApi.offSocketOpen(openHandler)
  300. if (typeof socketApi.offSocketMessage === 'function') socketApi.offSocketMessage(messageHandler)
  301. if (typeof socketApi.offSocketError === 'function') socketApi.offSocketError(errorHandler)
  302. if (typeof socketApi.offSocketClose === 'function') socketApi.offSocketClose(closeHandler)
  303. this.globalSocketHandlers = null
  304. }
  305. socketApi.closeSocket({
  306. complete: () => {
  307. this.socketTask = null
  308. this.useGlobalSocketApi = false
  309. this.isConnected = false
  310. this.connectingPromise = null
  311. resolve()
  312. }
  313. })
  314. return
  315. }
  316. if (typeof this.socketTask.close !== 'function') {
  317. this.socketTask = null
  318. this.useGlobalSocketApi = false
  319. this.isConnected = false
  320. this.connectingPromise = null
  321. resolve()
  322. return
  323. }
  324. this.socketTask.close({
  325. complete: () => {
  326. this.socketTask = null
  327. this.useGlobalSocketApi = false
  328. this.isConnected = false
  329. this.connectingPromise = null
  330. resolve()
  331. }
  332. })
  333. })
  334. }
  335. async send(payload) {
  336. if (!this.socketTask || !this.isConnected) {
  337. throw new Error('WebSocket 未连接')
  338. }
  339. const socketApi = typeof wx !== 'undefined' ? wx : uni
  340. return new Promise((resolve, reject) => {
  341. if (this.useGlobalSocketApi || this.socketTask.__global) {
  342. socketApi.sendSocketMessage({
  343. data: JSON.stringify(payload),
  344. success: () => resolve(true),
  345. fail: (error) => reject(error)
  346. })
  347. return
  348. }
  349. if (typeof this.socketTask.send !== 'function') {
  350. reject(new Error('WebSocket 发送不可用'))
  351. return
  352. }
  353. this.socketTask.send({
  354. data: JSON.stringify(payload),
  355. success: () => resolve(true),
  356. fail: (error) => reject(error)
  357. })
  358. })
  359. }
  360. async ensureConnected(options = {}) {
  361. console.log('[WebSocket] ensureConnected called', {
  362. isConnected: this.isConnected,
  363. hasSocketTask: !!this.socketTask,
  364. options,
  365. stack: getShortStack()
  366. })
  367. if (this.isConnected && this.socketTask) return true
  368. await this.connect(options)
  369. return true
  370. }
  371. }
  372. const websocketService = new WebSocketService()
  373. export default websocketService