From 76b68c0ea659baba757fc7875867ab4aa9610008 Mon Sep 17 00:00:00 2001 From: kyuuseiryuu Date: Sun, 22 Mar 2026 13:00:50 +0900 Subject: [PATCH] refactor(ws): unify WebSocket topic handling and add event subscription - Refactor `WebSocketService` and `common.ts` to use a unified topic system instead of custom prefixes. - Replace manual topic string concatenation with `getEventSubKey` and defined `WsServerSendTopics` types. - Update client-side components (`EventCard`, `GroupingPrediction`) to support real-time event subscriptions and notifications. - Move `useAuthSocket` and `WebScoketContext` initialization into `AppBarLayout` to ensure WebSocket state is available globally. - Add error handling to WebSocket message processing in the Bun server. - Implement a manual "Refresh Current Scores" button for `GroupingPrediction` to fetch fresh `nowScore` data. - Update `HydrateFallback` UI to display a loading message instead of a refresh button during long load times. - Add Service Worker (`sw.js`) build route to the Bun server configuration. --- src/components/AppBar.tsx | 8 +- src/components/EventCard.tsx | 61 +++++++++++- src/components/GroupingPrediction.tsx | 39 ++++++-- src/components/HydrateFallback.tsx | 4 +- src/components/Layout/AppBarLayout.tsx | 12 ++- src/components/WebSocketHandler/Handler.tsx | 0 .../WebSocketHandler/useAuthSocket.ts | 23 ----- src/context/WebsocketContext.tsx | 8 ++ src/hooks/useAuthSocket.ts | 35 +++++++ src/hooks/useHandlerServerMessage.tsx | 63 ++++++++++++ src/index.tsx | 29 +++++- src/routes.tsx | 4 +- src/services/WebsocketService.ts | 97 +++++++++++++------ src/sw.ts | 42 ++++++++ src/utils/common.ts | 67 ++++++++++--- src/utils/constants.ts | 3 +- 16 files changed, 411 insertions(+), 84 deletions(-) delete mode 100644 src/components/WebSocketHandler/Handler.tsx delete mode 100644 src/components/WebSocketHandler/useAuthSocket.ts create mode 100644 src/context/WebsocketContext.tsx create mode 100644 src/hooks/useAuthSocket.ts create mode 100644 src/hooks/useHandlerServerMessage.tsx create mode 100644 src/sw.ts diff --git a/src/components/AppBar.tsx b/src/components/AppBar.tsx index f4e3dfe..92f544d 100644 --- a/src/components/AppBar.tsx +++ b/src/components/AppBar.tsx @@ -29,22 +29,22 @@ export const AppBar = () => { , + diff --git a/src/components/HydrateFallback.tsx b/src/components/HydrateFallback.tsx index 1c4144f..2089670 100644 --- a/src/components/HydrateFallback.tsx +++ b/src/components/HydrateFallback.tsx @@ -1,4 +1,4 @@ -import { Button, Flex, Spin } from "antd"; +import { Button, Flex, Spin, Typography } from "antd"; import { useEffect, useState } from "react"; export function HydrateFallback() { @@ -13,7 +13,7 @@ export function HydrateFallback() { {tooLongTime && ( - + 桥豆麻袋,正在努力加载数据中.... )} ); diff --git a/src/components/Layout/AppBarLayout.tsx b/src/components/Layout/AppBarLayout.tsx index ce2862b..8409b37 100644 --- a/src/components/Layout/AppBarLayout.tsx +++ b/src/components/Layout/AppBarLayout.tsx @@ -2,6 +2,9 @@ import { Outlet, useNavigation } from "react-router"; import { HydrateFallback } from "../HydrateFallback"; import { AppBar } from "../AppBar"; import styled from "styled-components"; +import { useAuthSocket } from "../../hooks/useAuthSocket"; +import { WebScoketContext } from "../../context/WebsocketContext"; +import { Alert, Button } from "antd"; const StyledContainer = styled.div` padding-bottom: 90px; @@ -10,8 +13,13 @@ const StyledContainer = styled.div` export const AppBarLayout = () => { const navigation = useNavigation(); const loading = navigation.state === 'loading'; + const [sender] = useAuthSocket(); return loading ? : ( - - + + + + + + ); } \ No newline at end of file diff --git a/src/components/WebSocketHandler/Handler.tsx b/src/components/WebSocketHandler/Handler.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/components/WebSocketHandler/useAuthSocket.ts b/src/components/WebSocketHandler/useAuthSocket.ts deleted file mode 100644 index d63f5c0..0000000 --- a/src/components/WebSocketHandler/useAuthSocket.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useLogto } from "@logto/react"; -import { useRef } from "react" -import { useRequest } from "ahooks"; -import { LOGTO_RESOURCE } from "../../utils/constants"; - -export const useAuthSocket = () => { - const wsRef = useRef(null); - const { isAuthenticated, getAccessToken } = useLogto(); - const initWs = useRequest(async () => { - if (!isAuthenticated) return; - if (wsRef.current) { - if (wsRef.current.readyState === WebSocket.OPEN) { - return wsRef.current; - } - } - const token = await getAccessToken(LOGTO_RESOURCE); - const url = `${window.origin}/ws?token=${token}`.replace(/^http/, 'ws'); - const ws = new WebSocket(url); - wsRef.current = ws; - return ws; - }, { manual: false, refreshDeps: [isAuthenticated, getAccessToken], debounceWait: 300 }); - return initWs; -} \ No newline at end of file diff --git a/src/context/WebsocketContext.tsx b/src/context/WebsocketContext.tsx new file mode 100644 index 0000000..60105b8 --- /dev/null +++ b/src/context/WebsocketContext.tsx @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import type { WsWebSendTopicPayload, WsWebSendTopics } from "../utils/common"; + +interface MessageSender { + messageSender?: (topic: T, data: WsWebSendTopicPayload[T]) => void; +}; + +export const WebScoketContext = createContext({}); \ No newline at end of file diff --git a/src/hooks/useAuthSocket.ts b/src/hooks/useAuthSocket.ts new file mode 100644 index 0000000..607bbfd --- /dev/null +++ b/src/hooks/useAuthSocket.ts @@ -0,0 +1,35 @@ +import { useLogto } from "@logto/react"; +import { useCallback, useEffect, useState } from "react" +import { useWebSocket } from "ahooks"; +import { EVENT_WS_MESSAGE, LOGTO_RESOURCE } from "../utils/constants"; +import { fromServerMessage, toWebProcessMessage, type WsWebSendTopicPayload, type WsWebSendTopics } from "../utils/common"; + + +function getWSURL(token: string) { + if (!token) return ''; + return `${window.origin}/ws?token=${token}`.replace(/^http/, 'ws'); +} + +export const useAuthSocket = () => { + const { isAuthenticated, getAccessToken } = useLogto(); + const [token, setToken] = useState(''); + useEffect(() => { + if (!isAuthenticated) return; + getAccessToken(LOGTO_RESOURCE) + .then(token => setToken(token ?? '')); + }, [isAuthenticated]); + const result = useWebSocket(getWSURL(token), { + reconnectLimit: 3, + onMessage(message, instance) { + const event = new CustomEvent(EVENT_WS_MESSAGE, { detail: { + message: message.data, + instance, + } }); + window.dispatchEvent(event); + }, + }); + const sender = useCallback((topic: T, data: WsWebSendTopicPayload[T]) => { + result.sendMessage(toWebProcessMessage(topic, data)); + }, [result]); + return [sender]; +} \ No newline at end of file diff --git a/src/hooks/useHandlerServerMessage.tsx b/src/hooks/useHandlerServerMessage.tsx new file mode 100644 index 0000000..4403555 --- /dev/null +++ b/src/hooks/useHandlerServerMessage.tsx @@ -0,0 +1,63 @@ +import { useCallback, useEffect } from "react"; +import { ensureTopicData, fromServerMessage } from "../utils/common"; +import { EVENT_WS_MESSAGE } from "../utils/constants"; +import { App } from "antd"; + +export const useServerMessageHandler = () => { + const { message, notification } = App.useApp(); + const processCustomEvent = useCallback(async (msg: string, ws: WebSocket) => { + const { topic, data } = fromServerMessage(msg); + console.debug('Handle ws message, topic: %s', topic, data); + switch (topic) { + case "MEMBER_CHANGE": { + message.info({ + key: 'MEMBER_CHANGE', + content: `Online members: ${data}`, + }); + break; + } + case "MY_CLIENT_ONLINE": { + message.info({ + key: 'MY_CLIENT_ONLINE', + content: `New client online, clients: ${data}`, + }); + break; + } + case "DEBUG_MSG": + console.debug('DEBUG_MSG', data); + break; + case "EVENT_MEMBER_CHANGE": + case "MSG": { + const msgData = ensureTopicData<'MSG'>(data); + const hasPermission = await Notification.requestPermission(); + if (hasPermission === 'granted') { + const options = { + body: `有新的消息`, + icon: "https://example.com/icon.png", // 通知图标 + badge: "https://example.com/badge.png", // 移动端状态栏图标 + tag: `MSG`, // 相同 tag 的通知会覆盖,防止刷屏 + renotify: true // 覆盖旧通知时是否再次振动/提醒 + }; + new Notification("新消息提醒", options); + return; + } + notification.info({ + key: 'MSG', + title: 'You have a new message', + description: `from: ${msgData?.name}` + }); + break; + } + default: + break; + } + }, []); + useEffect(() => { + const handler = (e: Event) => { + const { message, instance } = (e as CustomEvent).detail; + processCustomEvent(message, instance); + }; + window.addEventListener(EVENT_WS_MESSAGE, handler); + return () => window.removeEventListener(EVENT_WS_MESSAGE, handler); + }, [processCustomEvent]); +}; \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 8ba5b6d..cd90654 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -23,6 +23,29 @@ const server = Bun.serve({ routes: { // Serve index.html for all unmatched routes. "/*": index, + "/sw.js": async () => { + const build = await Bun.build({ + entrypoints: ["./src/sw.ts"], + target: "browser", + // 如果你需要压缩,可以开启 + minify: process.env.NODE_ENV === "production", + }); + + if (!build.success) { + return new Response("Build Error", { status: 500 }); + } + + // 读取编译后的第一个输出文件(即 sw.js) + const blob = build.outputs[0]; + + return new Response(blob, { + headers: { + "Content-Type": "application/javascript", + // 开发环境下禁用缓存,确保 SW 能及时更新 + "Cache-Control": "no-cache", + }, + }); + }, "/api/club/find": { async GET(req) { const searchParams = new URL(req.url).searchParams; @@ -327,7 +350,11 @@ const server = Bun.serve({ WebSocketService.addConnection(ws); }, message(ws, message) { - WebSocketService.processMessage(ws, message); + try { + WebSocketService.processMessage(ws, message); + } catch(e) { + console.debug('Parse message error', e, message.toString()); + } }, close(ws, code, reason) { console.debug('close ws', code, reason) diff --git a/src/routes.tsx b/src/routes.tsx index 150df80..afefee2 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -55,8 +55,8 @@ export const route = createBrowserRouter([ const uidScore = await fetch(`/api/user/nowScores`, { method: "POST", body: JSON.stringify({ uids }), - }); - return { info, members, uidScore: new Map(Object.entries(await uidScore.json())) }; + }).then(res => res.json()).catch(() => ({})); + return { info, members, uidScore: new Map(Object.entries(uidScore)) }; }, Component: EventPage, HydrateFallback: () => diff --git a/src/services/WebsocketService.ts b/src/services/WebsocketService.ts index 1acebe4..1b827c1 100644 --- a/src/services/WebsocketService.ts +++ b/src/services/WebsocketService.ts @@ -1,9 +1,13 @@ -import { fromCustomMessage, toCustomMessage, WSTopic } from "../utils/common"; +import { ensureTopicData, fromCustomMessage, getEventSubKey, toWebProcessMessage, type WsServerSendTopicPayload, type WsServerSendTopics, type WsTopicPayload } from "../utils/common"; import { EventSubscribeService } from "./EventSubscribeService"; import type { WsPaylaod } from "../types"; -const publicTopics = [ - WSTopic.MEMBER_CHANGE, +const publicTopics: WsServerSendTopics[] = [ + 'MEMBER_CHANGE', + 'MY_CLIENT_ONLINE', + 'DEBUG_MSG', + 'EVENT_MEMBER_CHANGE', + "MSG", ]; type BunServerWebSocket = Bun.ServerWebSocket; @@ -23,24 +27,27 @@ export class WebSocketService { } this.#userClients.get(user.sub)?.add(ws); await this.#initSubscribe(ws); - const message = toCustomMessage(WSTopic.MEMBER_CHANGE, this.#userClients.size); if (isNewMember) { - this.broadcast(WSTopic.MEMBER_CHANGE, message); - } else { - ws.send(message); + this.broadcast('MEMBER_CHANGE', this.#userClients.size ?? 0); } this.userClientsBroadcast( ws.data.user.sub, - WSTopic.MY_CLIENT_ONLINE, - toCustomMessage(WSTopic.MY_CLIENT_ONLINE, this.#userClients.get(user.sub)?.size), + 'MY_CLIENT_ONLINE', + this.#userClients.get(user.sub)?.size ?? 0, + ); + this.userClientsBroadcast( + ws.data.user.sub, + 'DEBUG_MSG', + `SubscribeKeys:\n${[...(this.#userSubTopics.get(ws.data.user.sub) ?? [])].join('|')}`, ); } static async #initSubscribe(ws: BunServerWebSocket) { - const user = ws.data.user - const subEvets = await EventSubscribeService.getEvents(user.sub).then(e => e.map(v => `event:${v}`)); + const user = ws.data.user; + const subEvets = await EventSubscribeService.getEvents(user.sub).then(e => e.map(v => getEventSubKey(v))); this.userSub(ws, user.sub, publicTopics); this.userSub(ws, user.sub, subEvets); + this.userSub(ws, user.sub, [`MSG:${user.sub}`]); } static publish(ws: BunServerWebSocket, topic: string, message: string, withSelf?: boolean) { @@ -50,8 +57,11 @@ export class WebSocketService { } } - static async userClientsBroadcast(user: string, topic: string, message: string) { - this.#userClients.get(user)?.forEach(ws => ws.send(message)); + static async userClientsBroadcast(user: string, topic: T, data: WsServerSendTopicPayload[T]) { + this.#userClients.get(user)?.forEach(ws => { + if (!ws.isSubscribed(topic)) return; + ws.send(JSON.stringify({ topic, data })); + }); } static removeConnection(ws: BunServerWebSocket) { @@ -60,12 +70,12 @@ export class WebSocketService { this.#userClients.get(ws.data.user.sub)?.delete(ws); if (this.#userClients.get(ws.data.user.sub)?.size === 0) { this.#userClients.delete(ws.data.user.sub); - this.publish(ws, WSTopic.MEMBER_CHANGE, toCustomMessage(WSTopic.MEMBER_CHANGE, this.#userClients.size)); + this.publish(ws, 'MEMBER_CHANGE', toWebProcessMessage('MEMBER_CHANGE', this.#userClients.size)); } this.userClientsBroadcast( ws.data.user.sub, - WSTopic.MY_CLIENT_ONLINE, - toCustomMessage(WSTopic.MY_CLIENT_ONLINE, this.#userClients.get(ws.data.user.sub)?.size), + 'MY_CLIENT_ONLINE', + this.#userClients.get(ws.data.user.sub)?.size ?? 0, ); } @@ -74,17 +84,33 @@ export class WebSocketService { this.#userSubTopics.set(user, new Set()); } if (!topics.length) return; - console.debug('User %s subscribe keys: %s', user, topics.join(',')); topics.forEach(topic => { this.#userSubTopics.get(user)?.add(topic); ws.subscribe(topic); }); } - static broadcast(topic: string, message: string) { - this.#connections.values().forEach(con => { + static userNotificate(user: string, topic: T, data: WsTopicPayload[T]) { + const connections = this.#userClients.get(user); + if (!connections?.size) return; + connections.forEach(con => { if (con.isSubscribed(topic)) { - con.send(message); + con.sendText(toWebProcessMessage(topic, data)); + } + }); + } + + static broadcast(topic: T, data: WsTopicPayload[T], checkTopic?: T | string) { + console.debug('broadcast', { topic, data }); + this.#connections.values().forEach((con, i) => { + const isSubscribed = con.isSubscribed(topic || checkTopic); + console.debug(`CheckTopicSubscribed`, { + [topic]: con.isSubscribed(topic), + ...checkTopic ? { [checkTopic]: con.isSubscribed(checkTopic) } : {}, + }); + if (isSubscribed) { + console.debug('Send broadcast to [%s]: %s', i + 1, con.data.user.sub, { topic, data }); + con.send(toWebProcessMessage(topic, data)); } }); } @@ -94,17 +120,34 @@ export class WebSocketService { if (!action) return; console.debug('Recieve action: %s', action, message); switch (action) { - case WSTopic.SUB: - ws.subscribe(data.topic); + case 'SUB': { + const topic = ensureTopicData<'SUB'>(data)?.topic ?? ''; + ws.subscribe(topic); + if (!this.#userSubTopics.get(ws.data.user.sub)) { + this.#userSubTopics.set(ws.data.user.sub, new Set()); + } + this.#userSubTopics.get(ws.data.user.sub)?.add(topic); + console.debug('Subs', this.#userSubTopics.get(ws.data.user.sub)); + this.userClientsBroadcast(ws.data.user.sub, 'DEBUG_MSG', `Some client subscribed to: ${topic}`); break; - case WSTopic.UNSUB: - ws.unsubscribe(data.topic); + } + case 'UNSUB': { + const topic = ensureTopicData<'UNSUB'>(data)?.topic ?? ''; + ws.unsubscribe(topic); + if (!this.#userSubTopics.get(ws.data.user.sub)) { + this.#userSubTopics.set(ws.data.user.sub, new Set()); + } + this.#userSubTopics.get(ws.data.user.sub)?.delete(topic); + console.debug('Subs', this.#userSubTopics.get(ws.data.user.sub)); break; - case WSTopic.SEND: - ws.publish(data.topic, data.message); + } + case 'MSG': { + const msgData = ensureTopicData<'MSG'>(data); + if (!msgData) return; + this.broadcast('MSG', msgData, `MSG:${msgData.channel}`); break; + } default: - this.broadcast("Test", 'This is a broadcast message. Everyone should recieved.'); break; } } diff --git a/src/sw.ts b/src/sw.ts new file mode 100644 index 0000000..e357c47 --- /dev/null +++ b/src/sw.ts @@ -0,0 +1,42 @@ +/// + +const sw = self as unknown as ServiceWorkerGlobalScope; + +sw.addEventListener('install', (event) => { + console.log('SW Installed'); + sw.skipWaiting(); +}); + +// 核心:处理通知推送 +sw.addEventListener('push', (event) => { + const data = event.data?.json() ?? {}; + + event.waitUntil( + sw.registration.showNotification(data.title || '新通知', { + body: data.body || '您有一条新消息', + icon: '/pwa-192x192.png', + // 携带自定义数据,方便点击时跳转 + data: { url: data.url || '/' } + }) + ); +}); + +// 处理通知点击跳转 +sw.addEventListener('notificationclick', (event) => { + event.notification.close(); + const urlToOpen = event.notification.data.url; + + event.waitUntil( + sw.clients.matchAll({ type: 'window' }).then((windowClients) => { + // 如果已经打开了页面,则聚焦;否则打开新窗口 + for (const client of windowClients) { + if (client.url === urlToOpen && 'focus' in client) { + return client.focus(); + } + } + if (sw.clients.openWindow) { + return sw.clients.openWindow(urlToOpen); + } + }) + ); +}); \ No newline at end of file diff --git a/src/utils/common.ts b/src/utils/common.ts index 9fbbc33..b86b8ea 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -87,29 +87,72 @@ export function calculate(winerScore: number, loserScore: number) { } } -export function toCustomMessage(clientTopic: string | number, data: any) { - return JSON.stringify({ topic: clientTopic, data }); +export type WsServerSendTopicPayload = { + MEMBER_CHANGE: number; + MY_CLIENT_ONLINE: number; + DEBUG_MSG: string; + EVENT_MEMBER_CHANGE: { + event: string; + memberNum: number; + }; + MSG: { channel: string; name: string; avatar: string; message: string }; } -export enum WSTopic { - UNKONOW = 'UNKONW', - MY_CLIENT_ONLINE = 'CLIENT_ONLINE', - MEMBER_CHANGE = 'MEMBER_CHNAGE', - SUB = "SUB", - UNSUB = "UNSUB", - SEND = "PUBLISH", +export type WsServerSendTopics = keyof WsServerSendTopicPayload; + +export type WsWebSendTopicPayload = { + UNKONOW: undefined; + SUB: { topic: string }; + UNSUB: { topic: string }; + MSG: { channel: string; name: string; avatar: string; message: string }; +} + +export type WsWebSendTopics = keyof WsWebSendTopicPayload; + +export type WsTopicPayload = WsServerSendTopicPayload & WsWebSendTopicPayload; +export type WsTopics = keyof WsTopicPayload; + +export function ensureTopicData(data: any): WsTopicPayload[T] | undefined { + return data; +} + +export function getEventSubKey(eventId: string) { + return `event:${eventId}`; +} + +export function toWebProcessMessage( + topic: WsTopics, + data: WsTopicPayload[WsTopics], +) { + return JSON.stringify({ topic, data }); } export function fromCustomMessage(message: string): { - clientTopic: WSTopic; - data?: any; + clientTopic: WsWebSendTopics; + data?: WsWebSendTopicPayload[WsWebSendTopics]; } { try { const { topic: clientTopic, data } = JSON.parse(message); return { clientTopic, data }; } catch(e) { return { - clientTopic: WSTopic.UNKONOW, + clientTopic: 'UNKONOW', + data: undefined, }; } } + +export function fromServerMessage(message: string): { + topic: WsServerSendTopics; + data?: WsServerSendTopicPayload[WsServerSendTopics]; +} { + try { + const { topic, data } = JSON.parse(message); + return { topic, data }; + } catch(e) { + return { + topic: 'DEBUG_MSG', + data: `${e}`, + }; + } +} \ No newline at end of file diff --git a/src/utils/constants.ts b/src/utils/constants.ts index c66197c..16e2ac2 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,4 +1,5 @@ export const LOGTO_RESOURCE = 'https://tt.ksr.la'; export const CLUB_SELECTOR_KEY = 'CLUB_SELECTOR'; export const STORE_PAGE_LIST_KEY = 'events-page-keys'; -export const MATCH_RESULT_MAP_KEY = 'match-result-map'; \ No newline at end of file +export const MATCH_RESULT_MAP_KEY = 'match-result-map'; +export const EVENT_WS_MESSAGE = 'EVENT_WS_MESSAGE'; \ No newline at end of file