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

8
package-lock.json generated
View File

@@ -8,9 +8,11 @@
"name": "web-game", "name": "web-game",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"axios": "^1.17.0", "axios": "^1.17.0",
"cropperjs": "^2.1.1", "cropperjs": "^2.1.1",
"element-plus": "^2.14.2", "element-plus": "^2.14.2",
"mitt": "^3.0.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1", "pinia-plugin-persistedstate": "^4.7.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -740,6 +742,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",

View File

@@ -12,6 +12,7 @@
"format": "oxfmt src/" "format": "oxfmt src/"
}, },
"dependencies": { "dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"axios": "^1.17.0", "axios": "^1.17.0",
"cropperjs": "^2.1.1", "cropperjs": "^2.1.1",
"element-plus": "^2.14.2", "element-plus": "^2.14.2",

8
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@microsoft/fetch-event-source':
specifier: ^2.0.1
version: 2.0.1
axios: axios:
specifier: ^1.17.0 specifier: ^1.17.0
version: 1.17.0 version: 1.17.0
@@ -327,6 +330,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@microsoft/fetch-event-source@2.0.1':
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
'@napi-rs/wasm-runtime@1.1.4': '@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies: peerDependencies:
@@ -2237,6 +2243,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@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)': '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies: dependencies:
'@emnapi/core': 1.10.0 '@emnapi/core': 1.10.0

View File

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