feat: add unified WebSocket connection manager and routing
- Implemented UnifiedWebSocketManager for managing WebSocket connections, including subscription handling and message sending. - Created unified WebSocket router to handle client messages, including authentication, subscription, and chat session management. - Added support for logging and plugin progress subscriptions. - Enhanced error handling and response structure for WebSocket operations.
This commit is contained in:
161
dashboard/src/lib/chat-ws-client.ts
Normal file
161
dashboard/src/lib/chat-ws-client.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { unifiedWsClient, type ConnectionStatus } from './unified-ws'
|
||||
|
||||
interface ChatSessionOpenPayload {
|
||||
group_id?: string
|
||||
group_name?: string
|
||||
person_id?: string
|
||||
platform?: string
|
||||
user_id?: string
|
||||
user_name?: string
|
||||
}
|
||||
|
||||
type ChatSessionListener = (message: Record<string, unknown>) => void
|
||||
|
||||
class ChatWsClient {
|
||||
private initialized = false
|
||||
private listeners: Map<string, Set<ChatSessionListener>> = new Map()
|
||||
private sessionPayloads: Map<string, ChatSessionOpenPayload> = new Map()
|
||||
|
||||
private initialize(): void {
|
||||
if (this.initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
unifiedWsClient.addEventListener((message) => {
|
||||
if (message.domain !== 'chat' || !message.session) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionListeners = this.listeners.get(message.session)
|
||||
if (!sessionListeners) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(message.data)
|
||||
} catch (error) {
|
||||
console.error('聊天会话监听器执行失败:', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
unifiedWsClient.onReconnect(() => {
|
||||
void this.reopenSessions()
|
||||
})
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
private async reopenSessions(): Promise<void> {
|
||||
const reopenTargets = Array.from(this.sessionPayloads.entries())
|
||||
for (const [sessionId, payload] of reopenTargets) {
|
||||
try {
|
||||
await unifiedWsClient.call({
|
||||
domain: 'chat',
|
||||
method: 'session.open',
|
||||
session: sessionId,
|
||||
data: {
|
||||
...payload,
|
||||
restore: true,
|
||||
} as Record<string, unknown>,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`恢复聊天会话失败 (${sessionId}):`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async openSession(sessionId: string, payload: ChatSessionOpenPayload): Promise<void> {
|
||||
this.initialize()
|
||||
this.sessionPayloads.set(sessionId, payload)
|
||||
await unifiedWsClient.call({
|
||||
domain: 'chat',
|
||||
method: 'session.open',
|
||||
session: sessionId,
|
||||
data: payload as Record<string, unknown>,
|
||||
})
|
||||
}
|
||||
|
||||
async closeSession(sessionId: string): Promise<void> {
|
||||
this.sessionPayloads.delete(sessionId)
|
||||
if (unifiedWsClient.getStatus() !== 'connected') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await unifiedWsClient.call({
|
||||
domain: 'chat',
|
||||
method: 'session.close',
|
||||
session: sessionId,
|
||||
data: {},
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(`关闭聊天会话失败 (${sessionId}):`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(sessionId: string, content: string, userName: string): Promise<void> {
|
||||
await unifiedWsClient.call({
|
||||
domain: 'chat',
|
||||
method: 'message.send',
|
||||
session: sessionId,
|
||||
data: {
|
||||
content,
|
||||
user_name: userName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async updateNickname(sessionId: string, userName: string): Promise<void> {
|
||||
const currentPayload = this.sessionPayloads.get(sessionId)
|
||||
if (currentPayload) {
|
||||
this.sessionPayloads.set(sessionId, {
|
||||
...currentPayload,
|
||||
user_name: userName,
|
||||
})
|
||||
}
|
||||
|
||||
await unifiedWsClient.call({
|
||||
domain: 'chat',
|
||||
method: 'session.update_nickname',
|
||||
session: sessionId,
|
||||
data: {
|
||||
user_name: userName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onSessionMessage(sessionId: string, listener: ChatSessionListener): () => void {
|
||||
this.initialize()
|
||||
const sessionListeners = this.listeners.get(sessionId) ?? new Set<ChatSessionListener>()
|
||||
sessionListeners.add(listener)
|
||||
this.listeners.set(sessionId, sessionListeners)
|
||||
|
||||
return () => {
|
||||
const currentListeners = this.listeners.get(sessionId)
|
||||
if (!currentListeners) {
|
||||
return
|
||||
}
|
||||
|
||||
currentListeners.delete(listener)
|
||||
if (currentListeners.size === 0) {
|
||||
this.listeners.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onConnectionChange(listener: (connected: boolean) => void): () => void {
|
||||
return unifiedWsClient.onConnectionChange(listener)
|
||||
}
|
||||
|
||||
onStatusChange(listener: (status: ConnectionStatus) => void): () => void {
|
||||
return unifiedWsClient.onStatusChange(listener)
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
await unifiedWsClient.restart()
|
||||
}
|
||||
}
|
||||
|
||||
export const chatWsClient = new ChatWsClient()
|
||||
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* 全局日志 WebSocket 管理器
|
||||
* 确保整个应用只有一个 WebSocket 连接
|
||||
* 确保整个应用只通过统一连接层订阅日志流
|
||||
*/
|
||||
|
||||
import { checkAuthStatus } from './fetch-with-auth'
|
||||
import { getSetting } from './settings-manager'
|
||||
import { createReconnectingWebSocket } from './ws-utils'
|
||||
|
||||
import { getWsBaseUrl } from '@/lib/api-base'
|
||||
import { unifiedWsClient } from './unified-ws'
|
||||
|
||||
export interface LogEntry {
|
||||
id: string
|
||||
@@ -17,165 +15,79 @@ export interface LogEntry {
|
||||
message: string
|
||||
}
|
||||
|
||||
type LogCallback = (log: LogEntry) => void
|
||||
type LogCallback = () => void
|
||||
type ConnectionCallback = (connected: boolean) => void
|
||||
|
||||
class LogWebSocketManager {
|
||||
private wsControl: ReturnType<typeof createReconnectingWebSocket> | null = null
|
||||
|
||||
// 订阅者
|
||||
private logCallbacks: Set<LogCallback> = new Set()
|
||||
private connectionCallbacks: Set<ConnectionCallback> = new Set()
|
||||
|
||||
private initialized = false
|
||||
private isConnected = false
|
||||
|
||||
// 日志缓存 - 保存所有接收到的日志
|
||||
private logCache: LogEntry[] = []
|
||||
private logCallbacks: Set<LogCallback> = new Set()
|
||||
private subscriptionActive = false
|
||||
|
||||
/**
|
||||
* 获取最大缓存大小(从设置读取)
|
||||
*/
|
||||
private getMaxCacheSize(): number {
|
||||
return getSetting('logCacheSize')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最大重连次数(从设置读取)
|
||||
*/
|
||||
private getMaxReconnectAttempts(): number {
|
||||
return getSetting('wsMaxReconnectAttempts')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取重连间隔(从设置读取)
|
||||
*/
|
||||
private getReconnectInterval(): number {
|
||||
return getSetting('wsReconnectInterval')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket URL(不含 token 参数)
|
||||
*/
|
||||
private async getWebSocketUrl(): Promise<string> {
|
||||
const wsBase = await getWsBaseUrl()
|
||||
return `${wsBase}/ws/logs`
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 WebSocket(会先检查登录状态)
|
||||
*/
|
||||
async connect() {
|
||||
// 检查是否在登录页面
|
||||
if (window.location.pathname === '/auth') {
|
||||
console.log('📡 在登录页面,跳过 WebSocket 连接')
|
||||
private initialize(): void {
|
||||
if (this.initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查登录状态,避免未登录时尝试连接
|
||||
const isAuthenticated = await checkAuthStatus()
|
||||
if (!isAuthenticated) {
|
||||
console.log('📡 未登录,跳过 WebSocket 连接')
|
||||
return
|
||||
}
|
||||
unifiedWsClient.addEventListener((message) => {
|
||||
if (message.domain !== 'logs') {
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = await this.getWebSocketUrl()
|
||||
if (message.event === 'snapshot') {
|
||||
const entries = Array.isArray(message.data.entries)
|
||||
? (message.data.entries as LogEntry[])
|
||||
: []
|
||||
this.logCache = entries.slice(-this.getMaxCacheSize())
|
||||
this.notifyLogChange()
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 ws-utils 创建 WebSocket
|
||||
this.wsControl = createReconnectingWebSocket(wsUrl, {
|
||||
onMessage: (data: string) => {
|
||||
try {
|
||||
const log: LogEntry = JSON.parse(data)
|
||||
this.notifyLog(log)
|
||||
} catch (error) {
|
||||
console.error('解析日志消息失败:', error)
|
||||
}
|
||||
},
|
||||
onOpen: () => {
|
||||
this.isConnected = true
|
||||
this.notifyConnection(true)
|
||||
},
|
||||
onClose: () => {
|
||||
this.isConnected = false
|
||||
this.notifyConnection(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ WebSocket 错误:', error)
|
||||
this.isConnected = false
|
||||
this.notifyConnection(false)
|
||||
},
|
||||
heartbeatInterval: 30000,
|
||||
maxRetries: this.getMaxReconnectAttempts(),
|
||||
backoffBase: this.getReconnectInterval(),
|
||||
maxBackoff: 30000,
|
||||
if (message.event === 'entry' && message.data.entry) {
|
||||
this.appendLog(message.data.entry as LogEntry)
|
||||
}
|
||||
})
|
||||
|
||||
// 启动连接
|
||||
await this.wsControl.connect()
|
||||
unifiedWsClient.onConnectionChange((connected) => {
|
||||
this.isConnected = connected
|
||||
this.notifyConnection(connected)
|
||||
})
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.wsControl) {
|
||||
this.wsControl.disconnect()
|
||||
this.wsControl = null
|
||||
}
|
||||
|
||||
this.isConnected = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅日志消息
|
||||
*/
|
||||
onLog(callback: LogCallback) {
|
||||
this.logCallbacks.add(callback)
|
||||
return () => this.logCallbacks.delete(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅连接状态
|
||||
*/
|
||||
onConnectionChange(callback: ConnectionCallback) {
|
||||
this.connectionCallbacks.add(callback)
|
||||
// 立即通知当前状态
|
||||
callback(this.isConnected)
|
||||
return () => this.connectionCallbacks.delete(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知所有订阅者新日志
|
||||
*/
|
||||
private notifyLog(log: LogEntry) {
|
||||
// 检查是否已存在(通过 id 去重)
|
||||
private appendLog(log: LogEntry): void {
|
||||
const exists = this.logCache.some(existingLog => existingLog.id === log.id)
|
||||
|
||||
if (!exists) {
|
||||
// 添加到缓存
|
||||
this.logCache.push(log)
|
||||
|
||||
// 限制缓存大小(动态读取配置)
|
||||
const maxCacheSize = this.getMaxCacheSize()
|
||||
if (this.logCache.length > maxCacheSize) {
|
||||
this.logCache = this.logCache.slice(-maxCacheSize)
|
||||
}
|
||||
|
||||
// 只有新日志才通知订阅者
|
||||
this.logCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(log)
|
||||
} catch (error) {
|
||||
console.error('日志回调执行失败:', error)
|
||||
}
|
||||
})
|
||||
if (exists) {
|
||||
return
|
||||
}
|
||||
|
||||
this.logCache.push(log)
|
||||
const maxCacheSize = this.getMaxCacheSize()
|
||||
if (this.logCache.length > maxCacheSize) {
|
||||
this.logCache = this.logCache.slice(-maxCacheSize)
|
||||
}
|
||||
this.notifyLogChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知所有订阅者连接状态变化
|
||||
*/
|
||||
private notifyConnection(connected: boolean) {
|
||||
this.connectionCallbacks.forEach(callback => {
|
||||
private notifyLogChange(): void {
|
||||
this.logCallbacks.forEach((callback) => {
|
||||
try {
|
||||
callback()
|
||||
} catch (error) {
|
||||
console.error('日志回调执行失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private notifyConnection(connected: boolean): void {
|
||||
this.connectionCallbacks.forEach((callback) => {
|
||||
try {
|
||||
callback(connected)
|
||||
} catch (error) {
|
||||
@@ -184,35 +96,65 @@ class LogWebSocketManager {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的所有日志
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (window.location.pathname === '/auth') {
|
||||
return
|
||||
}
|
||||
|
||||
const isAuthenticated = await checkAuthStatus()
|
||||
if (!isAuthenticated) {
|
||||
return
|
||||
}
|
||||
|
||||
this.initialize()
|
||||
if (this.subscriptionActive) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await unifiedWsClient.subscribe('logs', 'main', { replay: 100 })
|
||||
this.subscriptionActive = true
|
||||
} catch (error) {
|
||||
console.error('订阅日志流失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.subscriptionActive = false
|
||||
void unifiedWsClient.unsubscribe('logs', 'main')
|
||||
this.isConnected = false
|
||||
this.notifyConnection(false)
|
||||
}
|
||||
|
||||
onLog(callback: LogCallback): () => void {
|
||||
this.logCallbacks.add(callback)
|
||||
return () => this.logCallbacks.delete(callback)
|
||||
}
|
||||
|
||||
onConnectionChange(callback: ConnectionCallback): () => void {
|
||||
this.connectionCallbacks.add(callback)
|
||||
callback(this.isConnected)
|
||||
return () => this.connectionCallbacks.delete(callback)
|
||||
}
|
||||
|
||||
getAllLogs(): LogEntry[] {
|
||||
return [...this.logCache]
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志缓存
|
||||
*/
|
||||
clearLogs() {
|
||||
clearLogs(): void {
|
||||
this.logCache = []
|
||||
this.notifyLogChange()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前连接状态
|
||||
*/
|
||||
getConnectionStatus(): boolean {
|
||||
return this.isConnected
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const logWebSocket = new LogWebSocketManager()
|
||||
|
||||
// 自动连接(应用启动时)
|
||||
if (typeof window !== 'undefined') {
|
||||
// 延迟一下确保页面加载完成
|
||||
setTimeout(() => {
|
||||
logWebSocket.connect()
|
||||
void logWebSocket.connect()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import type { PluginInfo } from '@/types/plugin'
|
||||
|
||||
import { getWsBaseUrl } from '@/lib/api-base'
|
||||
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||
import { parseResponse } from '@/lib/api-helpers'
|
||||
import { pluginProgressClient } from '@/lib/plugin-progress-client'
|
||||
import type { GitStatus, MaimaiVersion } from './types'
|
||||
|
||||
/**
|
||||
@@ -211,41 +211,13 @@ export function isPluginCompatible(
|
||||
*/
|
||||
export async function connectPluginProgressWebSocket(
|
||||
onProgress: (progress: import('./types').PluginLoadProgress) => void,
|
||||
onError?: (error: Event) => void
|
||||
): Promise<WebSocket | null> {
|
||||
const wsBase = await getWsBaseUrl()
|
||||
const wsUrl = `${wsBase}/api/webui/ws/plugin-progress`
|
||||
|
||||
// 使用 ws-utils 创建 WebSocket
|
||||
const { createReconnectingWebSocket } = await import('@/lib/ws-utils')
|
||||
const wsControl = createReconnectingWebSocket(wsUrl, {
|
||||
onMessage: (data: string) => {
|
||||
try {
|
||||
const progressData = JSON.parse(data) as import('./types').PluginLoadProgress
|
||||
onProgress(progressData)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse progress data:', error)
|
||||
}
|
||||
},
|
||||
onOpen: () => {
|
||||
console.log('Plugin progress WebSocket connected')
|
||||
},
|
||||
onClose: () => {
|
||||
console.log('Plugin progress WebSocket disconnected')
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Plugin progress WebSocket error:', error)
|
||||
onError?.(error)
|
||||
},
|
||||
heartbeatInterval: 30000,
|
||||
maxRetries: 10,
|
||||
backoffBase: 1000,
|
||||
maxBackoff: 30000,
|
||||
})
|
||||
|
||||
// 启动连接
|
||||
await wsControl.connect()
|
||||
|
||||
// 返回 WebSocket 实例(用于外部检查连接状态)
|
||||
return wsControl.getWebSocket()
|
||||
onError?: (error: Error) => void
|
||||
): Promise<() => Promise<void>> {
|
||||
try {
|
||||
return await pluginProgressClient.subscribe(onProgress)
|
||||
} catch (error) {
|
||||
const normalizedError = error instanceof Error ? error : new Error('插件进度订阅失败')
|
||||
onError?.(normalizedError)
|
||||
return async () => {}
|
||||
}
|
||||
}
|
||||
|
||||
58
dashboard/src/lib/plugin-progress-client.ts
Normal file
58
dashboard/src/lib/plugin-progress-client.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { PluginLoadProgress } from '@/lib/plugin-api/types'
|
||||
|
||||
import { unifiedWsClient } from './unified-ws'
|
||||
|
||||
type ProgressListener = (progress: PluginLoadProgress) => void
|
||||
|
||||
class PluginProgressClient {
|
||||
private initialized = false
|
||||
private listeners: Set<ProgressListener> = new Set()
|
||||
private subscriptionActive = false
|
||||
|
||||
private initialize(): void {
|
||||
if (this.initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
unifiedWsClient.addEventListener((message) => {
|
||||
if (message.domain !== 'plugin_progress') {
|
||||
return
|
||||
}
|
||||
|
||||
const progress = message.data.progress as PluginLoadProgress | undefined
|
||||
if (!progress) {
|
||||
return
|
||||
}
|
||||
|
||||
this.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(progress)
|
||||
} catch (error) {
|
||||
console.error('插件进度监听器执行失败:', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
async subscribe(listener: ProgressListener): Promise<() => Promise<void>> {
|
||||
this.initialize()
|
||||
this.listeners.add(listener)
|
||||
|
||||
if (!this.subscriptionActive) {
|
||||
await unifiedWsClient.subscribe('plugin_progress', 'main')
|
||||
this.subscriptionActive = true
|
||||
}
|
||||
|
||||
return async () => {
|
||||
this.listeners.delete(listener)
|
||||
if (this.listeners.size === 0 && this.subscriptionActive) {
|
||||
this.subscriptionActive = false
|
||||
await unifiedWsClient.unsubscribe('plugin_progress', 'main')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginProgressClient = new PluginProgressClient()
|
||||
495
dashboard/src/lib/unified-ws.ts
Normal file
495
dashboard/src/lib/unified-ws.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import { fetchWithAuth } from './fetch-with-auth'
|
||||
import { getSetting } from './settings-manager'
|
||||
|
||||
import { getWsBaseUrl } from '@/lib/api-base'
|
||||
|
||||
export type ConnectionStatus = 'idle' | 'connecting' | 'connected'
|
||||
|
||||
export interface WsErrorPayload {
|
||||
code?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface WsEventEnvelope {
|
||||
op: 'event'
|
||||
domain: string
|
||||
event: string
|
||||
session?: string
|
||||
topic?: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface WsResponseEnvelope {
|
||||
op: 'response'
|
||||
id?: string
|
||||
ok: boolean
|
||||
data?: Record<string, unknown>
|
||||
error?: WsErrorPayload
|
||||
}
|
||||
|
||||
interface WsPongEnvelope {
|
||||
op: 'pong'
|
||||
ts: number
|
||||
}
|
||||
|
||||
type WsServerEnvelope = WsEventEnvelope | WsPongEnvelope | WsResponseEnvelope
|
||||
|
||||
interface PendingRequest {
|
||||
reject: (error: Error) => void
|
||||
resolve: (data: Record<string, unknown>) => void
|
||||
timeoutId: number
|
||||
}
|
||||
|
||||
interface SubscriptionDefinition {
|
||||
data?: Record<string, unknown>
|
||||
domain: string
|
||||
topic: string
|
||||
}
|
||||
|
||||
type EventListener = (message: WsEventEnvelope) => void
|
||||
type ConnectionListener = (connected: boolean) => void
|
||||
type StatusListener = (status: ConnectionStatus) => void
|
||||
type ReconnectListener = () => void
|
||||
|
||||
function isResponseEnvelope(message: WsServerEnvelope): message is WsResponseEnvelope {
|
||||
return message.op === 'response'
|
||||
}
|
||||
|
||||
function isEventEnvelope(message: WsServerEnvelope): message is WsEventEnvelope {
|
||||
return message.op === 'event'
|
||||
}
|
||||
|
||||
async function getWsToken(): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/webui/ws-token', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.success && data.token) {
|
||||
return data.token as string
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('获取统一 WebSocket token 失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class UnifiedWebSocketClient {
|
||||
private connectPromise: Promise<void> | null = null
|
||||
private connectionListeners: Set<ConnectionListener> = new Set()
|
||||
private eventListeners: Set<EventListener> = new Set()
|
||||
private hasConnectedOnce = false
|
||||
private heartbeatIntervalId: number | null = null
|
||||
private manualDisconnect = false
|
||||
private pendingRequests: Map<string, PendingRequest> = new Map()
|
||||
private reconnectAttempts = 0
|
||||
private reconnectListeners: Set<ReconnectListener> = new Set()
|
||||
private reconnectTimeout: number | null = null
|
||||
private requestCounter = 0
|
||||
private status: ConnectionStatus = 'idle'
|
||||
private statusListeners: Set<StatusListener> = new Set()
|
||||
private subscriptions: Map<string, SubscriptionDefinition> = new Map()
|
||||
private ws: WebSocket | null = null
|
||||
|
||||
private getReconnectDelay(): number {
|
||||
const baseDelay = getSetting('wsReconnectInterval')
|
||||
return Math.min(baseDelay * Math.max(this.reconnectAttempts, 1), 30000)
|
||||
}
|
||||
|
||||
private getMaxReconnectAttempts(): number {
|
||||
return getSetting('wsMaxReconnectAttempts')
|
||||
}
|
||||
|
||||
private getSubscriptionKey(domain: string, topic: string): string {
|
||||
return `${domain}:${topic}`
|
||||
}
|
||||
|
||||
private nextRequestId(): string {
|
||||
this.requestCounter += 1
|
||||
return `ws-${Date.now()}-${this.requestCounter}`
|
||||
}
|
||||
|
||||
private setStatus(status: ConnectionStatus): void {
|
||||
if (this.status === status) {
|
||||
return
|
||||
}
|
||||
|
||||
this.status = status
|
||||
this.statusListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(status)
|
||||
} catch (error) {
|
||||
console.error('WebSocket 状态监听器执行失败:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const connected = status === 'connected'
|
||||
this.connectionListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(connected)
|
||||
} catch (error) {
|
||||
console.error('WebSocket 连接监听器执行失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatIntervalId !== null) {
|
||||
clearInterval(this.heartbeatIntervalId)
|
||||
this.heartbeatIntervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
this.heartbeatIntervalId = window.setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ op: 'ping' }))
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimeout !== null) {
|
||||
clearTimeout(this.reconnectTimeout)
|
||||
this.reconnectTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
private rejectPendingRequests(error: Error): void {
|
||||
this.pendingRequests.forEach((pendingRequest, requestId) => {
|
||||
clearTimeout(pendingRequest.timeoutId)
|
||||
pendingRequest.reject(error)
|
||||
this.pendingRequests.delete(requestId)
|
||||
})
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.manualDisconnect) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.reconnectAttempts >= this.getMaxReconnectAttempts()) {
|
||||
console.warn(`统一 WebSocket 达到最大重连次数 (${this.getMaxReconnectAttempts()}),停止重连`)
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts += 1
|
||||
const delay = this.getReconnectDelay()
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectTimeout = window.setTimeout(() => {
|
||||
void this.connect().catch((error) => {
|
||||
console.error('统一 WebSocket 重连失败:', error)
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private async createWebSocketUrl(): Promise<string | null> {
|
||||
const wsBaseUrl = await getWsBaseUrl()
|
||||
const wsToken = await getWsToken()
|
||||
if (!wsBaseUrl || !wsToken) {
|
||||
return null
|
||||
}
|
||||
return `${wsBaseUrl}/api/webui/ws?token=${encodeURIComponent(wsToken)}`
|
||||
}
|
||||
|
||||
private async sendRequest(
|
||||
payload: Record<string, unknown>,
|
||||
timeoutMs = 10000,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('统一 WebSocket 尚未连接')
|
||||
}
|
||||
|
||||
const requestId = payload.id as string
|
||||
return await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId)
|
||||
reject(new Error(`统一 WebSocket 请求超时: ${requestId}`))
|
||||
}, timeoutMs)
|
||||
|
||||
this.pendingRequests.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
})
|
||||
this.ws?.send(JSON.stringify(payload))
|
||||
})
|
||||
}
|
||||
|
||||
private async restoreState(shouldNotifyReconnect: boolean): Promise<void> {
|
||||
const subscriptions = Array.from(this.subscriptions.values())
|
||||
for (const subscription of subscriptions) {
|
||||
try {
|
||||
await this.sendRequest({
|
||||
op: 'subscribe',
|
||||
id: this.nextRequestId(),
|
||||
domain: subscription.domain,
|
||||
topic: subscription.topic,
|
||||
data: subscription.data ?? {},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('恢复统一 WebSocket 订阅失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldNotifyReconnect) {
|
||||
this.reconnectListeners.forEach((listener) => {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
console.error('统一 WebSocket 重连监听器执行失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleServerMessage(rawData: string): void {
|
||||
let message: WsServerEnvelope
|
||||
try {
|
||||
message = JSON.parse(rawData) as WsServerEnvelope
|
||||
} catch (error) {
|
||||
console.error('解析统一 WebSocket 消息失败:', error)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.op === 'pong') {
|
||||
return
|
||||
}
|
||||
|
||||
if (isResponseEnvelope(message)) {
|
||||
const requestId = message.id
|
||||
if (!requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
const pendingRequest = this.pendingRequests.get(requestId)
|
||||
if (!pendingRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(pendingRequest.timeoutId)
|
||||
this.pendingRequests.delete(requestId)
|
||||
if (message.ok) {
|
||||
pendingRequest.resolve(message.data ?? {})
|
||||
} else {
|
||||
pendingRequest.reject(new Error(message.error?.message ?? '统一 WebSocket 请求失败'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isEventEnvelope(message)) {
|
||||
this.eventListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(message)
|
||||
} catch (error) {
|
||||
console.error('统一 WebSocket 事件监听器执行失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose(event: CloseEvent): void {
|
||||
this.stopHeartbeat()
|
||||
this.ws = null
|
||||
this.connectPromise = null
|
||||
this.setStatus('idle')
|
||||
this.rejectPendingRequests(new Error(`统一 WebSocket 已关闭 (${event.code})`))
|
||||
|
||||
if (event.code === 4001) {
|
||||
this.manualDisconnect = true
|
||||
if (window.location.pathname !== '/auth') {
|
||||
window.location.href = '/auth'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.connectPromise) {
|
||||
return await this.connectPromise
|
||||
}
|
||||
|
||||
this.manualDisconnect = false
|
||||
this.setStatus('connecting')
|
||||
|
||||
this.connectPromise = (async () => {
|
||||
const wsUrl = await this.createWebSocketUrl()
|
||||
if (!wsUrl) {
|
||||
this.setStatus('idle')
|
||||
throw new Error('无法建立统一 WebSocket 连接')
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
const socket = new WebSocket(wsUrl)
|
||||
this.ws = socket
|
||||
|
||||
socket.onopen = () => {
|
||||
settled = true
|
||||
const shouldNotifyReconnect = this.hasConnectedOnce
|
||||
this.hasConnectedOnce = true
|
||||
this.reconnectAttempts = 0
|
||||
this.startHeartbeat()
|
||||
this.setStatus('connected')
|
||||
resolve()
|
||||
void this.restoreState(shouldNotifyReconnect)
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
this.handleServerMessage(event.data)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
reject(new Error('统一 WebSocket 连接失败'))
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = (event) => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
reject(new Error(`统一 WebSocket 已关闭 (${event.code})`))
|
||||
}
|
||||
this.handleClose(event)
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
try {
|
||||
await this.connectPromise
|
||||
} finally {
|
||||
if (this.status !== 'connected') {
|
||||
this.connectPromise = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.manualDisconnect = true
|
||||
this.clearReconnectTimer()
|
||||
this.stopHeartbeat()
|
||||
this.rejectPendingRequests(new Error('统一 WebSocket 已手动断开'))
|
||||
this.connectPromise = null
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this.setStatus('idle')
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
this.manualDisconnect = false
|
||||
this.clearReconnectTimer()
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
return
|
||||
}
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
async call(params: {
|
||||
data?: Record<string, unknown>
|
||||
domain: string
|
||||
method: string
|
||||
session?: string
|
||||
}): Promise<Record<string, unknown>> {
|
||||
await this.connect()
|
||||
const requestId = this.nextRequestId()
|
||||
return await this.sendRequest({
|
||||
op: 'call',
|
||||
id: requestId,
|
||||
domain: params.domain,
|
||||
method: params.method,
|
||||
session: params.session,
|
||||
data: params.data ?? {},
|
||||
})
|
||||
}
|
||||
|
||||
async subscribe(
|
||||
domain: string,
|
||||
topic: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await this.connect()
|
||||
this.subscriptions.set(this.getSubscriptionKey(domain, topic), {
|
||||
domain,
|
||||
topic,
|
||||
data,
|
||||
})
|
||||
|
||||
return await this.sendRequest({
|
||||
op: 'subscribe',
|
||||
id: this.nextRequestId(),
|
||||
domain,
|
||||
topic,
|
||||
data: data ?? {},
|
||||
})
|
||||
}
|
||||
|
||||
async unsubscribe(domain: string, topic: string): Promise<Record<string, unknown> | null> {
|
||||
this.subscriptions.delete(this.getSubscriptionKey(domain, topic))
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await this.sendRequest({
|
||||
op: 'unsubscribe',
|
||||
id: this.nextRequestId(),
|
||||
domain,
|
||||
topic,
|
||||
data: {},
|
||||
})
|
||||
}
|
||||
|
||||
addEventListener(listener: EventListener): () => void {
|
||||
this.eventListeners.add(listener)
|
||||
return () => {
|
||||
this.eventListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
onConnectionChange(listener: ConnectionListener): () => void {
|
||||
this.connectionListeners.add(listener)
|
||||
listener(this.status === 'connected')
|
||||
return () => {
|
||||
this.connectionListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
onStatusChange(listener: StatusListener): () => void {
|
||||
this.statusListeners.add(listener)
|
||||
listener(this.status)
|
||||
return () => {
|
||||
this.statusListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
onReconnect(listener: ReconnectListener): () => void {
|
||||
this.reconnectListeners.add(listener)
|
||||
return () => {
|
||||
this.reconnectListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
getStatus(): ConnectionStatus {
|
||||
return this.status
|
||||
}
|
||||
}
|
||||
|
||||
export const unifiedWsClient = new UnifiedWebSocketClient()
|
||||
@@ -1,211 +0,0 @@
|
||||
import { fetchWithAuth } from './fetch-with-auth'
|
||||
|
||||
/**
|
||||
* WebSocket 配置选项
|
||||
*/
|
||||
export interface WebSocketOptions {
|
||||
onMessage?: (data: string) => void
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
onError?: (error: Event) => void
|
||||
heartbeatInterval?: number // 心跳间隔(毫秒)
|
||||
maxRetries?: number // 最大重连次数
|
||||
backoffBase?: number // 重连基础间隔(毫秒)
|
||||
maxBackoff?: number // 最大重连间隔(毫秒)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 临时认证 token
|
||||
*/
|
||||
export async function getWsToken(): Promise<string | null> {
|
||||
try {
|
||||
// 使用相对路径,让前端代理处理请求,避免 CORS 问题
|
||||
const response = await fetchWithAuth('/api/webui/ws-token', {
|
||||
method: 'GET',
|
||||
credentials: 'include', // 携带 Cookie
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('获取 WebSocket token 失败:', response.status)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.success && data.token) {
|
||||
return data.token
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('获取 WebSocket token 失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带重连、心跳的 WebSocket 封装
|
||||
*
|
||||
* @param url WebSocket URL(不含 token 参数)
|
||||
* @param options 配置选项
|
||||
* @returns WebSocket 控制对象,包含 connect、disconnect、send 方法
|
||||
*/
|
||||
export function createReconnectingWebSocket(
|
||||
url: string,
|
||||
options: WebSocketOptions = {}
|
||||
) {
|
||||
const {
|
||||
onMessage,
|
||||
onOpen,
|
||||
onClose,
|
||||
onError,
|
||||
heartbeatInterval = 30000,
|
||||
maxRetries = 10,
|
||||
backoffBase = 1000,
|
||||
maxBackoff = 30000,
|
||||
} = options
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimeout: number | null = null
|
||||
let reconnectAttempts = 0
|
||||
let heartbeatIntervalId: number | null = null
|
||||
let isManualDisconnect = false
|
||||
|
||||
/**
|
||||
* 启动心跳
|
||||
*/
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat()
|
||||
heartbeatIntervalId = window.setInterval(() => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send('ping')
|
||||
}
|
||||
}, heartbeatInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
*/
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatIntervalId !== null) {
|
||||
clearInterval(heartbeatIntervalId)
|
||||
heartbeatIntervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试重连
|
||||
*/
|
||||
function attemptReconnect() {
|
||||
if (isManualDisconnect) {
|
||||
return
|
||||
}
|
||||
|
||||
if (reconnectAttempts >= maxRetries) {
|
||||
console.warn(`WebSocket 达到最大重连次数 (${maxRetries}),停止重连`)
|
||||
return
|
||||
}
|
||||
|
||||
reconnectAttempts += 1
|
||||
const delay = Math.min(backoffBase * reconnectAttempts, maxBackoff)
|
||||
|
||||
console.log(`WebSocket 将在 ${delay}ms 后重连(第 ${reconnectAttempts} 次)`)
|
||||
reconnectTimeout = window.setTimeout(() => {
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 WebSocket
|
||||
*/
|
||||
async function connect() {
|
||||
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) {
|
||||
return
|
||||
}
|
||||
|
||||
// 先获取临时认证 token
|
||||
const wsToken = await getWsToken()
|
||||
if (!wsToken) {
|
||||
console.warn('无法获取 WebSocket token,跳过连接')
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = `${url}?token=${encodeURIComponent(wsToken)}`
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0
|
||||
startHeartbeat()
|
||||
onOpen?.()
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// 忽略心跳响应
|
||||
if (event.data === 'pong') {
|
||||
return
|
||||
}
|
||||
onMessage?.(event.data)
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket 错误:', error)
|
||||
onError?.(error)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
stopHeartbeat()
|
||||
onClose?.()
|
||||
attemptReconnect()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建 WebSocket 连接失败:', error)
|
||||
attemptReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
function disconnect() {
|
||||
isManualDisconnect = true
|
||||
|
||||
if (reconnectTimeout !== null) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = null
|
||||
}
|
||||
|
||||
stopHeartbeat()
|
||||
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
|
||||
reconnectAttempts = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
function send(data: string) {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
} else {
|
||||
console.warn('WebSocket 未连接,无法发送消息')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 WebSocket 实例
|
||||
*/
|
||||
function getWebSocket(): WebSocket | null {
|
||||
return ws
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
send,
|
||||
getWebSocket,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user