feat: 使用@microsoft/fetch-event-source库
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -8,9 +8,11 @@
|
||||
"name": "web-game",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"axios": "^1.17.0",
|
||||
"cropperjs": "^2.1.1",
|
||||
"element-plus": "^2.14.2",
|
||||
"mitt": "^3.0.1",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -740,6 +742,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/fetch-event-source": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz",
|
||||
"integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"format": "oxfmt src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"axios": "^1.17.0",
|
||||
"cropperjs": "^2.1.1",
|
||||
"element-plus": "^2.14.2",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@microsoft/fetch-event-source':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
axios:
|
||||
specifier: ^1.17.0
|
||||
version: 1.17.0
|
||||
@@ -327,6 +330,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@microsoft/fetch-event-source@2.0.1':
|
||||
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.4':
|
||||
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
||||
peerDependencies:
|
||||
@@ -2237,6 +2243,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@microsoft/fetch-event-source@2.0.1': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
|
||||
@@ -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 写入同名 Cookie,EventSource 同域请求自动携带)
|
||||
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-forget,onerror 本身保持同步)
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user