初始化模版工程
This commit is contained in:
570
app/api/websocket/index.ts
Normal file
570
app/api/websocket/index.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* WebSocket 客户端封装
|
||||
*
|
||||
* 提供自动重连、心跳检测、网络状态监听等功能
|
||||
*/
|
||||
|
||||
function latest<T>(value: T) {
|
||||
const ref = { current: value }
|
||||
return ref
|
||||
}
|
||||
|
||||
export const ReadyState = {
|
||||
Connecting: 0,
|
||||
Open: 1,
|
||||
Closing: 2,
|
||||
Closed: 3,
|
||||
} as const
|
||||
|
||||
export type ReadyState = (typeof ReadyState)[keyof typeof ReadyState]
|
||||
|
||||
export interface Result {
|
||||
sendMessage: WebSocket['send']
|
||||
disconnect: () => void
|
||||
connect: () => void
|
||||
readyState: ReadyState
|
||||
webSocketIns?: WebSocket
|
||||
clearHeartbeat: () => void
|
||||
switchConversation: (conversationId: string) => void
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
export interface HeartbeatOptions {
|
||||
/** 心跳间隔,默认 20000ms */
|
||||
heartbeatInterval?: number
|
||||
/** 心跳超时,默认 22000ms */
|
||||
heartbeatTimeout?: number
|
||||
/** 心跳消息,默认 { message_type: 'ping' } */
|
||||
heartbeatMessage?: string | object | (() => string | object)
|
||||
/** 心跳响应类型,默认 'pong' */
|
||||
heartbeatResponseType?: string
|
||||
}
|
||||
|
||||
export interface ApiEvent {
|
||||
event_id: string
|
||||
event_type?: string
|
||||
role?: 'user' | 'assistant' | 'system'
|
||||
content?: {
|
||||
text?: string
|
||||
content?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
created_at?: string
|
||||
task_id?: string
|
||||
is_display?: boolean
|
||||
metadata?: Record<string, unknown>
|
||||
stream?: boolean
|
||||
event_status?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// WebSocket 事件类型定义
|
||||
type WSOpenEvent = Event
|
||||
interface WSCloseEvent {
|
||||
code?: number
|
||||
reason?: string
|
||||
wasClean?: boolean
|
||||
}
|
||||
interface WSMessageEvent {
|
||||
data: string | ArrayBuffer | Blob
|
||||
}
|
||||
type WSErrorEvent = Event
|
||||
|
||||
export interface Options {
|
||||
/** 重连次数限制,默认 3 */
|
||||
reconnectLimit?: number
|
||||
/** 重连间隔,默认 3000ms */
|
||||
reconnectInterval?: number
|
||||
/** 是否手动连接,默认 false */
|
||||
manual?: boolean
|
||||
/** 连接成功回调 */
|
||||
onOpen?: (event: WSOpenEvent, instance: WebSocket) => void
|
||||
/** 连接关闭回调 */
|
||||
onClose?: (event: WSCloseEvent, instance: WebSocket) => void
|
||||
/** 收到消息回调 */
|
||||
onMessage?: (message: ApiEvent, instance: WebSocket) => void
|
||||
/** 连接错误回调 */
|
||||
onError?: (event: WSErrorEvent, instance: WebSocket) => void
|
||||
/** WebSocket 协议 */
|
||||
protocols?: string | string[]
|
||||
/** 心跳配置,传 false 禁用心跳 */
|
||||
heartbeat?: HeartbeatOptions | boolean
|
||||
/** 是否监听网络状态变化,默认 true */
|
||||
enableNetworkListener?: boolean
|
||||
/** 是否监听文档可见性变化,默认 true */
|
||||
enableVisibilityListener?: boolean
|
||||
/** 重连延迟,默认 300ms */
|
||||
reconnectDelay?: number
|
||||
/** 重连防抖时间,默认 1000ms */
|
||||
reconnectDebounce?: number
|
||||
/** 获取认证 Token */
|
||||
getToken?: () => string | undefined
|
||||
/** 获取租户 ID */
|
||||
getTenantId?: () => string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 WebSocket 客户端
|
||||
*/
|
||||
export function createWebSocketClient(
|
||||
socketUrl: string,
|
||||
options: Options = {},
|
||||
): Result {
|
||||
const {
|
||||
reconnectLimit = 3,
|
||||
reconnectInterval = 3 * 1000,
|
||||
manual = false,
|
||||
onOpen,
|
||||
onClose,
|
||||
onMessage,
|
||||
onError,
|
||||
protocols,
|
||||
heartbeat: enableHeartbeat = true,
|
||||
enableNetworkListener = true,
|
||||
enableVisibilityListener = true,
|
||||
reconnectDelay = 300,
|
||||
reconnectDebounce = 1000,
|
||||
getToken,
|
||||
getTenantId,
|
||||
} = options
|
||||
|
||||
const heartbeatOptions: HeartbeatOptions =
|
||||
typeof enableHeartbeat === 'object' ? enableHeartbeat : {}
|
||||
const {
|
||||
heartbeatInterval = 20000,
|
||||
heartbeatTimeout = 22000,
|
||||
heartbeatMessage = { message_type: 'ping' },
|
||||
heartbeatResponseType = 'pong',
|
||||
} = heartbeatOptions
|
||||
|
||||
// 提前声明函数,避免使用前未定义
|
||||
let disconnectFn: () => void = () => {
|
||||
throw new Error('disconnectFn not initialized')
|
||||
}
|
||||
let connectWsFn: () => void = () => {
|
||||
throw new Error('connectWsFn not initialized')
|
||||
}
|
||||
|
||||
const onOpenRef = latest(onOpen)
|
||||
const onCloseRef = latest(onClose)
|
||||
const onMessageRef = latest(onMessage)
|
||||
const onErrorRef = latest(onError)
|
||||
|
||||
// 确保 ref 始终指向最新的回调
|
||||
if (onMessage) {
|
||||
onMessageRef.current = onMessage
|
||||
}
|
||||
|
||||
const reconnectTimesRef = latest(0)
|
||||
const reconnectTimerRef = latest<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const websocketRef = latest<WebSocket | undefined>(undefined)
|
||||
const readyStateRef = latest<ReadyState>(ReadyState.Closed)
|
||||
|
||||
// 心跳相关
|
||||
const heartbeatTimerRef = latest<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const heartbeatTimeoutTimerRef = latest<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const waitingForPongRef = latest(false)
|
||||
|
||||
// 网络和可见性状态
|
||||
const isOnlineRef = latest(typeof navigator !== 'undefined' ? navigator.onLine : true)
|
||||
const isVisibleRef = latest(typeof document !== 'undefined' ? !document.hidden : true)
|
||||
|
||||
// 重连防抖定时器
|
||||
const reconnectDebounceTimerRef = latest<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
// 更新 readyState 的辅助函数
|
||||
const setReadyState = (state: ReadyState) => {
|
||||
readyStateRef.current = state
|
||||
}
|
||||
|
||||
// 获取当前 readyState
|
||||
const getReadyState = (): ReadyState => {
|
||||
if (websocketRef.current) {
|
||||
const wsState = websocketRef.current.readyState
|
||||
if (wsState === WebSocket.CONNECTING) return ReadyState.Connecting
|
||||
if (wsState === WebSocket.OPEN) return ReadyState.Open
|
||||
if (wsState === WebSocket.CLOSING) return ReadyState.Closing
|
||||
if (wsState === WebSocket.CLOSED) return ReadyState.Closed
|
||||
}
|
||||
return readyStateRef.current
|
||||
}
|
||||
|
||||
// 清除心跳相关定时器
|
||||
const clearHeartbeat = () => {
|
||||
if (heartbeatTimerRef.current) {
|
||||
clearTimeout(heartbeatTimerRef.current)
|
||||
heartbeatTimerRef.current = undefined
|
||||
}
|
||||
if (heartbeatTimeoutTimerRef.current) {
|
||||
clearTimeout(heartbeatTimeoutTimerRef.current)
|
||||
heartbeatTimeoutTimerRef.current = undefined
|
||||
}
|
||||
waitingForPongRef.current = false
|
||||
}
|
||||
|
||||
// 处理心跳超时
|
||||
const handlePingTimeout = () => {
|
||||
if (!isOnlineRef.current) {
|
||||
waitingForPongRef.current = false
|
||||
clearHeartbeat()
|
||||
return
|
||||
}
|
||||
waitingForPongRef.current = false
|
||||
clearHeartbeat()
|
||||
disconnectFn()
|
||||
}
|
||||
|
||||
// 发送心跳
|
||||
const sendHeartbeat = () => {
|
||||
if (!isOnlineRef.current) {
|
||||
return
|
||||
}
|
||||
if (waitingForPongRef.current) {
|
||||
return
|
||||
}
|
||||
if (websocketRef.current && getReadyState() === ReadyState.Open) {
|
||||
try {
|
||||
const message =
|
||||
typeof heartbeatMessage === 'function'
|
||||
? heartbeatMessage()
|
||||
: heartbeatMessage
|
||||
websocketRef.current.send(
|
||||
typeof message === 'string' ? message : JSON.stringify(message),
|
||||
)
|
||||
waitingForPongRef.current = true
|
||||
|
||||
heartbeatTimeoutTimerRef.current = setTimeout(
|
||||
handlePingTimeout,
|
||||
heartbeatTimeout,
|
||||
)
|
||||
} catch {
|
||||
clearHeartbeat()
|
||||
}
|
||||
} else {
|
||||
clearHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理心跳响应
|
||||
const handlePongReceived = () => {
|
||||
if (!waitingForPongRef.current) {
|
||||
return
|
||||
}
|
||||
waitingForPongRef.current = false
|
||||
|
||||
if (heartbeatTimeoutTimerRef.current) {
|
||||
clearTimeout(heartbeatTimeoutTimerRef.current)
|
||||
heartbeatTimeoutTimerRef.current = undefined
|
||||
}
|
||||
|
||||
heartbeatTimerRef.current = setTimeout(sendHeartbeat, heartbeatInterval)
|
||||
}
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
if (!enableHeartbeat) return
|
||||
clearHeartbeat()
|
||||
heartbeatTimerRef.current = setTimeout(sendHeartbeat, 1000)
|
||||
}
|
||||
|
||||
// 处理原始消息,检查是否是心跳响应
|
||||
const handleRawMessage = (messageData: string): boolean => {
|
||||
try {
|
||||
const rawMessage = JSON.parse(messageData)
|
||||
if (
|
||||
rawMessage.data?.message_type === heartbeatResponseType ||
|
||||
rawMessage.message_type === heartbeatResponseType
|
||||
) {
|
||||
handlePongReceived()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const reconnect = () => {
|
||||
if (
|
||||
reconnectTimesRef.current < reconnectLimit &&
|
||||
getReadyState() !== ReadyState.Open
|
||||
) {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current)
|
||||
}
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
connectWsFn()
|
||||
reconnectTimesRef.current++
|
||||
}, reconnectInterval)
|
||||
}
|
||||
}
|
||||
|
||||
connectWsFn = () => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current)
|
||||
}
|
||||
|
||||
if (websocketRef.current) {
|
||||
websocketRef.current.close()
|
||||
}
|
||||
|
||||
// 构建 WebSocket URL
|
||||
const url = new URL(socketUrl)
|
||||
|
||||
// 添加认证信息
|
||||
const token = getToken?.()
|
||||
const tenantId = getTenantId?.()
|
||||
|
||||
if (token) {
|
||||
url.searchParams.set('Authorization', token)
|
||||
}
|
||||
if (tenantId) {
|
||||
url.searchParams.set('Tenant-Id', tenantId)
|
||||
}
|
||||
|
||||
const ws = new WebSocket(url.toString(), protocols)
|
||||
setReadyState(ReadyState.Connecting)
|
||||
|
||||
ws.onerror = event => {
|
||||
if (websocketRef.current !== ws) {
|
||||
return
|
||||
}
|
||||
reconnect()
|
||||
onErrorRef.current?.(event, ws)
|
||||
setReadyState(ReadyState.Closed)
|
||||
}
|
||||
|
||||
ws.onopen = event => {
|
||||
if (websocketRef.current !== ws) {
|
||||
return
|
||||
}
|
||||
onOpenRef.current?.(event, ws)
|
||||
reconnectTimesRef.current = 0
|
||||
setReadyState(ReadyState.Open)
|
||||
startHeartbeat()
|
||||
}
|
||||
|
||||
ws.onmessage = (message: WSMessageEvent) => {
|
||||
if (websocketRef.current !== ws) {
|
||||
return
|
||||
}
|
||||
|
||||
const messageData =
|
||||
typeof message.data === 'string' ? message.data : String(message.data)
|
||||
|
||||
// 先检查是否是心跳响应
|
||||
if (enableHeartbeat && handleRawMessage(messageData)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 解析消息并触发回调
|
||||
try {
|
||||
const parsedMessage: ApiEvent = JSON.parse(messageData)
|
||||
onMessageRef.current?.(parsedMessage, ws)
|
||||
} catch {
|
||||
// 如果解析失败,尝试作为原始数据传递
|
||||
console.warn('[WebSocket] Failed to parse message:', messageData)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = event => {
|
||||
onCloseRef.current?.(event, ws)
|
||||
clearHeartbeat()
|
||||
// closed by server
|
||||
if (websocketRef.current === ws) {
|
||||
reconnect()
|
||||
}
|
||||
// closed by disconnect or closed by server
|
||||
if (!websocketRef.current || websocketRef.current === ws) {
|
||||
setReadyState(ReadyState.Closed)
|
||||
}
|
||||
}
|
||||
|
||||
websocketRef.current = ws
|
||||
}
|
||||
|
||||
const sendMessage: WebSocket['send'] = message => {
|
||||
const currentState = getReadyState()
|
||||
if (currentState === ReadyState.Open) {
|
||||
websocketRef.current?.send(message)
|
||||
} else {
|
||||
throw new Error('WebSocket disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换会话
|
||||
const switchConversation = (conversationId: string) => {
|
||||
// 检查网络状态
|
||||
if (!isOnlineRef.current) {
|
||||
throw new Error('网络连接异常,无法切换会话')
|
||||
}
|
||||
|
||||
// 检查 WebSocket 连接状态
|
||||
const currentState = getReadyState()
|
||||
if (!websocketRef.current || currentState !== ReadyState.Open) {
|
||||
throw new Error('WebSocket 未连接,无法切换会话')
|
||||
}
|
||||
|
||||
try {
|
||||
const message = JSON.stringify({
|
||||
message_type: 'switch_conversation',
|
||||
conversation_id: conversationId,
|
||||
})
|
||||
websocketRef.current.send(message)
|
||||
} catch (error) {
|
||||
throw new Error(`切换会话失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
reconnectTimesRef.current = 0
|
||||
connectWsFn()
|
||||
}
|
||||
|
||||
disconnectFn = () => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current)
|
||||
}
|
||||
if (reconnectDebounceTimerRef.current) {
|
||||
clearTimeout(reconnectDebounceTimerRef.current)
|
||||
}
|
||||
|
||||
reconnectTimesRef.current = reconnectLimit
|
||||
clearHeartbeat()
|
||||
websocketRef.current?.close(1000, '手动断开')
|
||||
websocketRef.current = undefined
|
||||
setReadyState(ReadyState.Closed)
|
||||
}
|
||||
|
||||
// 处理网络断开
|
||||
const handleNetworkOffline = () => {
|
||||
clearHeartbeat()
|
||||
const currentState = getReadyState()
|
||||
if (
|
||||
currentState === ReadyState.Open ||
|
||||
currentState === ReadyState.Connecting
|
||||
) {
|
||||
disconnectFn()
|
||||
}
|
||||
}
|
||||
|
||||
// 重连函数 - 统一处理重连逻辑
|
||||
const attemptReconnect = () => {
|
||||
// 清除之前的防抖定时器
|
||||
if (reconnectDebounceTimerRef.current) {
|
||||
clearTimeout(reconnectDebounceTimerRef.current)
|
||||
}
|
||||
|
||||
reconnectDebounceTimerRef.current = setTimeout(() => {
|
||||
const currentState = getReadyState()
|
||||
// 已连接或正在连接时跳过
|
||||
if (
|
||||
currentState === ReadyState.Open ||
|
||||
currentState === ReadyState.Connecting
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
clearHeartbeat()
|
||||
|
||||
const isClosed =
|
||||
currentState === ReadyState.Closed ||
|
||||
currentState === ReadyState.Closing
|
||||
|
||||
if (isClosed) {
|
||||
connect()
|
||||
} else {
|
||||
disconnectFn()
|
||||
setTimeout(() => {
|
||||
if (
|
||||
isOnlineRef.current &&
|
||||
getReadyState() !== ReadyState.Open &&
|
||||
getReadyState() !== ReadyState.Connecting
|
||||
) {
|
||||
connect()
|
||||
}
|
||||
}, reconnectDelay)
|
||||
}
|
||||
}, reconnectDebounce)
|
||||
}
|
||||
|
||||
// 网络状态监听
|
||||
let cleanupNetworkListener: (() => void) | undefined
|
||||
|
||||
if (enableNetworkListener && typeof window !== 'undefined') {
|
||||
const handleOnline = () => {
|
||||
isOnlineRef.current = true
|
||||
attemptReconnect()
|
||||
}
|
||||
|
||||
const handleOffline = () => {
|
||||
isOnlineRef.current = false
|
||||
handleNetworkOffline()
|
||||
}
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
cleanupNetworkListener = () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}
|
||||
|
||||
// 文档可见性监听
|
||||
let cleanupVisibilityListener: (() => void) | undefined
|
||||
|
||||
if (enableVisibilityListener && typeof document !== 'undefined') {
|
||||
const handleVisibilityChange = () => {
|
||||
const isVisible = !document.hidden
|
||||
isVisibleRef.current = isVisible
|
||||
if (isVisible && isOnlineRef.current) {
|
||||
const currentState = getReadyState()
|
||||
if (
|
||||
currentState === ReadyState.Closed ||
|
||||
currentState === ReadyState.Closing
|
||||
) {
|
||||
attemptReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
cleanupVisibilityListener = () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}
|
||||
|
||||
// 自动连接
|
||||
if (!manual && socketUrl) {
|
||||
connect()
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
const cleanup = () => {
|
||||
disconnectFn()
|
||||
cleanupNetworkListener?.()
|
||||
cleanupVisibilityListener?.()
|
||||
}
|
||||
|
||||
const result: Result = {
|
||||
sendMessage,
|
||||
connect,
|
||||
disconnect: disconnectFn,
|
||||
get readyState() {
|
||||
return getReadyState()
|
||||
},
|
||||
get webSocketIns() {
|
||||
return websocketRef.current
|
||||
},
|
||||
clearHeartbeat,
|
||||
switchConversation,
|
||||
cleanup,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export default createWebSocketClient
|
||||
Reference in New Issue
Block a user