Files
webgame-frontend/src/common/sse/SseClient.ts

174 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { ElMessage } from 'element-plus'
/**
* SSE 连接客户端
*
* 只负责连接生命周期(建连、断开、重连、心跳),不包含任何业务逻辑。
* 收到消息后通过 onMessage 回调抛出,由业务层自行分发。
*/
export class SseClient {
private abortController: AbortController | null = null
private retryCount = 0
private _connected = false
private _connecting = false
/** 重连配置 */
private readonly maxRetries: number
private readonly baseRetryDelayMs: number
private readonly maxRetryDelayMs: number
constructor(
private readonly url: string,
private readonly getToken: () => string | null,
private readonly callbacks: SseClientCallbacks,
opts?: SseClientOptions
) {
this.maxRetries = opts?.maxRetries ?? 5
this.baseRetryDelayMs = opts?.baseRetryDelayMs ?? 3000
this.maxRetryDelayMs = opts?.maxRetryDelayMs ?? 30000
}
get connected(): boolean {
return this._connected
}
get connecting(): boolean {
return this._connecting
}
/**
* 建立 SSE 连接
*
* 幂等调用:已连接或正在连接则跳过。
* 连接断开时自动指数退避重连,最多 maxRetries 次。
*/
async connect(): Promise<void> {
const token = this.getToken()
if (!token) {
this.callbacks.onError?.('缺少 accessToken')
return
}
// 幂等:相同 token 已连接或正在连接则跳过
if (this._connected || this._connecting) {
return
}
this.disconnect()
this._connecting = true
this.abortController = new AbortController()
try {
await fetchEventSource(this.url, {
headers: { saToken: token },
signal: this.abortController.signal,
openWhenHidden: true,
onopen: async (response) => {
const contentType = response.headers.get('content-type') || ''
if (response.status === 401) {
throw new SseHttpError('SSE 鉴权失败', response)
}
if (!response.ok) {
throw new SseHttpError(`SSE 连接失败: ${response.status}`, response)
}
if (!contentType.includes('text/event-stream')) {
throw new SseHttpError(`非法的响应类型: ${contentType || '空'}`, response)
}
this._connecting = false
this._connected = true
this.retryCount = 0
this.callbacks.onConnected?.()
},
onmessage: (message) => {
if (!message.event || !message.data) {
return
}
this.callbacks.onMessage(message.event, message.data)
},
onclose: () => {
this._connecting = false
this._connected = false
},
onerror: (error) => {
this._connecting = false
this._connected = false
// 401 → 通知外部刷新 token
if (isSseHttpError(error) && error.response.status === 401) {
this.callbacks.onUnauthorized?.()
throw error
}
this.retryCount += 1
if (this.retryCount >= this.maxRetries) {
this.callbacks.onError?.('实时连接异常,请刷新页面')
throw error
}
const delay = Math.min(
this.baseRetryDelayMs * Math.pow(2, this.retryCount - 1),
this.maxRetryDelayMs
)
return delay
}
})
} catch (error) {
// fetchEventSource 仅在重连耗尽或 401 时 throw状态已在 onerror 中处理
}
}
/** 断开连接(主动关闭,不重连) */
disconnect(): void {
if (this.abortController) {
this.abortController.abort()
this.abortController = null
}
this.retryCount = 0
this._connected = false
this._connecting = false
}
}
// ==================== 类型定义 ====================
export interface SseClientCallbacks {
/** 连接成功 */
onConnected?: () => void
/** 收到消息 (eventName, rawData) */
onMessage: (event: string, data: string) => void
/** 连接异常(重连耗尽或 token 缺失) */
onError?: (message: string) => void
/** 收到 401需刷新 token */
onUnauthorized?: () => void
}
export interface SseClientOptions {
maxRetries?: number
baseRetryDelayMs?: number
maxRetryDelayMs?: number
}
// ==================== 内部工具 ====================
class SseHttpError extends Error {
constructor(
message: string,
readonly response: Response
) {
super(message)
this.name = 'SseHttpError'
}
}
function isSseHttpError(error: unknown): error is SseHttpError {
return error instanceof SseHttpError
}