113 行
3.2 KiB
TypeScript
113 行
3.2 KiB
TypeScript
|
|
import type { ImMessage, SendMessageParams, ImEventMap } from '../types'
|
||
|
|
import { getConfig, getToken } from '../core/sdk'
|
||
|
|
|
||
|
|
type EventListener<K extends keyof ImEventMap> = ImEventMap[K]
|
||
|
|
|
||
|
|
const MAX_RECONNECT_DELAY = 30_000
|
||
|
|
|
||
|
|
export class ImClient {
|
||
|
|
private ws: WebSocket | null = null
|
||
|
|
private reconnectDelay = 3_000
|
||
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
|
private destroyed = false
|
||
|
|
private listeners: { [K in keyof ImEventMap]?: Set<EventListener<K>> } = {}
|
||
|
|
|
||
|
|
on<K extends keyof ImEventMap>(event: K, handler: EventListener<K>): this {
|
||
|
|
if (!this.listeners[event]) {
|
||
|
|
(this.listeners[event] as Set<EventListener<K>>) = new Set()
|
||
|
|
}
|
||
|
|
(this.listeners[event] as Set<EventListener<K>>).add(handler)
|
||
|
|
return this
|
||
|
|
}
|
||
|
|
|
||
|
|
off<K extends keyof ImEventMap>(event: K, handler: EventListener<K>): this {
|
||
|
|
(this.listeners[event] as Set<EventListener<K>> | undefined)?.delete(handler)
|
||
|
|
return this
|
||
|
|
}
|
||
|
|
|
||
|
|
private emit<K extends keyof ImEventMap>(event: K, ...args: Parameters<ImEventMap[K]>): void {
|
||
|
|
(this.listeners[event] as Set<(...a: unknown[]) => void> | undefined)?.forEach((h) =>
|
||
|
|
h(...(args as unknown[]))
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
connect(): void {
|
||
|
|
if (this.destroyed) return
|
||
|
|
const config = getConfig()
|
||
|
|
const token = getToken()
|
||
|
|
const url = `${config.imBaseUrl}/ws/im?token=${token ?? ''}`
|
||
|
|
|
||
|
|
this.ws = new WebSocket(url)
|
||
|
|
|
||
|
|
this.ws.onopen = () => {
|
||
|
|
this.reconnectDelay = 3_000
|
||
|
|
if (config.debug) console.log('[ImClient] connected')
|
||
|
|
this.emit('connected')
|
||
|
|
}
|
||
|
|
|
||
|
|
this.ws.onmessage = (event) => {
|
||
|
|
try {
|
||
|
|
const frame = JSON.parse(event.data as string)
|
||
|
|
if (frame.type === 'MESSAGE') {
|
||
|
|
this.emit('message', frame.payload as ImMessage)
|
||
|
|
} else if (frame.type === 'REVOKE') {
|
||
|
|
this.emit('revoke', frame.payload as { msgId: string; operatorId: string })
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// ignore malformed frames
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
this.ws.onclose = (event) => {
|
||
|
|
this.emit('disconnected', event.code, event.reason)
|
||
|
|
if (!this.destroyed) this.scheduleReconnect()
|
||
|
|
}
|
||
|
|
|
||
|
|
this.ws.onerror = (event) => {
|
||
|
|
this.emit('error', event)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
send(params: SendMessageParams): void {
|
||
|
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||
|
|
throw new Error('WebSocket not connected')
|
||
|
|
}
|
||
|
|
this.ws.send(
|
||
|
|
JSON.stringify({
|
||
|
|
destination: '/app/chat.send',
|
||
|
|
payload: params,
|
||
|
|
})
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
revoke(msgId: string): void {
|
||
|
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||
|
|
throw new Error('WebSocket not connected')
|
||
|
|
}
|
||
|
|
this.ws.send(
|
||
|
|
JSON.stringify({
|
||
|
|
destination: '/app/chat.revoke',
|
||
|
|
payload: { msgId },
|
||
|
|
})
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
disconnect(): void {
|
||
|
|
this.destroyed = true
|
||
|
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
||
|
|
this.ws?.close()
|
||
|
|
this.ws = null
|
||
|
|
}
|
||
|
|
|
||
|
|
private scheduleReconnect(): void {
|
||
|
|
if (this.destroyed) return
|
||
|
|
if (getConfig().debug) {
|
||
|
|
console.log(`[ImClient] reconnect in ${this.reconnectDelay}ms`)
|
||
|
|
}
|
||
|
|
this.reconnectTimer = setTimeout(() => {
|
||
|
|
this.connect()
|
||
|
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||
|
|
}, this.reconnectDelay)
|
||
|
|
}
|
||
|
|
}
|