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",
|
"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",
|
||||||
|
|||||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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 写入同名 Cookie,EventSource 同域请求自动携带)
|
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-forget,onerror 本身保持同步)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user