feat: 使用@microsoft/fetch-event-source库

This commit is contained in:
2026-06-23 10:13:53 +08:00
parent 70c4bab3f6
commit 0383572d39
4 changed files with 128 additions and 61 deletions

View File

@@ -1,13 +1,16 @@
import { ref } from 'vue'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { sseBus } from '@/stores/sseBus'
import { useAccountStore } from '@/stores/modules/account'
/**
* SSE 连接管理 composable
* SSE 连接管理 composable(基于 @microsoft/fetch-event-source
*
* 登录后调用 connect() 建立全局唯一的 SSE 长连接。
* 收到服务端事件后解析 JSON 并通过 sseBus 分发给订阅者。
* EventSource 原生自动重连,重试 3 次失败后通知用户刷新。
*
* 鉴权:通过 fetch-event-source 的 headers 传 saToken和 axios 走同一套机制。
* 401 时精确读取 err.response.status 触发 token 刷新,刷新成功自动重连。
*
* 使用示例:
* ```ts
@@ -18,18 +21,20 @@ import { useAccountStore } from '@/stores/modules/account'
export function useSse() {
/** SSE 连接是否已建立 */
const isConnected = ref(false)
/** 重试次数EventSource 每次 onerror 自增open 后归零) */
const retryCount = ref(0)
/** EventSource 实例 */
let es: EventSource | null = null
/** 用于取消 inflight 连接的控制器 */
let abortController: AbortController | null = null
/** 当前连接的重试计数 */
let retryCount = 0
/** 是否正在刷新 token防并发 */
let isRefreshing = false
/**
* 建立 SSE 连接
*
* 从 Pinia store 获取 accessToken 拼接到 URL 查询参数
* EventSource 不支持自定义请求头Sa-Token 支持从 ?token= 参数读取
* 通过 fetch-event-source 发起,设置 saToken 请求头鉴权
* 连接期间持续接收服务端推送事件,直到 abort 或服务端关闭
*/
function connect(): void {
async function connect(): Promise<void> {
const accountStore = useAccountStore()
const token = accountStore.accessToken
if (!token) {
@@ -37,66 +42,117 @@ export function useSse() {
return
}
// 已有连接则先关闭
if (es) {
es.close()
}
// 取消上一次连接
abort()
abortController = new AbortController()
// Vite 代理: /api/sse/subscribe → 后端 /sse/subscribe
// 鉴权走 Cookie登录后 Sa-Token 写入同名 CookieEventSource 同域请求自动携带)
const url = `/api/sse/subscribe`
es = new EventSource(url)
fetchEventSource('/api/sse/subscribe', {
headers: { saToken: token },
signal: abortController.signal,
es.onopen = () => {
isConnected.value = true
retryCount.value = 0
console.log('[SSE] 连接已建立')
}
/** 连接建立 */
async onopen() {
isConnected.value = true
retryCount = 0
console.log('[SSE] 连接已建立')
},
// 服务端定义的业务事件类型
const eventTypes = [
'connected',
'room_created',
'room_updated',
'room_removed',
'kicked',
'game_started',
'balance_chg'
]
for (const type of eventTypes) {
es.addEventListener(type, (e: Event) => {
const msg = e as MessageEvent
// heartbeat 和 connected 确认消息可能为空数据
/** 收到服务端事件 */
onmessage(msg) {
// 心跳和 connected 确认消息可能 data 为空
if (!msg.data) return
try {
const data = JSON.parse(msg.data)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sseBus.emit(type as any, data)
} catch (err) {
console.warn('[SSE] 事件数据解析失败:', type, msg.data, err)
sseBus.emit(msg.event as any, data)
} catch {
console.warn('[SSE] 事件数据解析失败:', msg.event, msg.data)
}
})
}
},
// heartbeat 事件(服务端保活,前端忽略即可)
es.addEventListener('heartbeat', () => {
// NOOP: 仅用于保活
})
/** 连接关闭 */
onclose() {
isConnected.value = false
console.log('[SSE] 连接已关闭')
},
// 连接错误处理EventSource 会自动重连)
es.onerror = () => {
isConnected.value = false
retryCount.value++
/**
* 连接错误处理(必须同步,签名: (err) => number | void
*
* 401 → fire-and-forget 刷新 token阻止当前连接重连
* 刷新成功后由 Vue watcher 触发 connect() 自动重连
* 其他错误 → 返回重试延迟ms库自动排队重连
*/
onerror(err) {
isConnected.value = false
if (retryCount.value >= 3) {
sseBus.emit('error', {
message: '实时连接异常,请刷新页面',
retryCount: retryCount.value
})
// 鉴权失败 → 刷新 token异步 fire-and-forgetonerror 本身保持同步)
if (err.response?.status === 401) {
if (isRefreshing) {
// 已有刷新在进行中,直接放弃本次连接
throw err
}
isRefreshing = true
const store = useAccountStore()
if (!store.refreshToken) {
sseBus.emit('error', { message: '登录已过期,请重新登录', retryCount: 0 })
throw err
}
// 异步刷新,不阻塞 onerror 返回
store
.refreshAccessToken()
.then((newToken) => {
if (newToken) {
console.log('[SSE] token 刷新成功,重新建立连接')
retryCount = 0
// connect() 由 App.vue 的 watch 触发accessToken 变更)
}
})
.catch(() => {
console.warn('[SSE] token 刷新失败')
sseBus.emit('error', { message: '实时连接异常,请刷新页面', retryCount: 0 })
})
.finally(() => {
isRefreshing = false
})
throw err // 不自动重连
}
// 其他错误:指数退避重连
retryCount++
if (retryCount >= 5) {
sseBus.emit('error', {
message: '实时连接异常,请刷新页面',
retryCount
})
throw err // 超过阈值,停止重连
}
// 返回重试间隔ms指数退避3s, 6s, 12s, 24s
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 30000)
console.warn(`[SSE] 连接错误,${delay / 1000}s 后重试 (${retryCount}/5)`)
return delay
}
})
}
/**
* 取消当前 SSE 连接
*/
function abort(): void {
if (abortController) {
abortController.abort()
abortController = null
}
isConnected.value = false
retryCount = 0
}
/**
@@ -105,18 +161,12 @@ export function useSse() {
* 在用户登出时调用。
*/
function disconnect(): void {
if (es) {
es.close()
es = null
}
isConnected.value = false
retryCount.value = 0
abort()
console.log('[SSE] 连接已断开')
}
return {
isConnected,
retryCount,
connect,
disconnect
}