diff --git a/package-lock.json b/package-lock.json index c826ce3..ef1a4ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index fc23e7b..849d299 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 333cd7d..29cfe2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/common/hooks/useSse.ts b/src/common/hooks/useSse.ts index a754b3e..866e2e6 100644 --- a/src/common/hooks/useSse.ts +++ b/src/common/hooks/useSse.ts @@ -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 { 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 }