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.
This commit is contained in:
kyuuseiryuu 2026-03-22 13:00:50 +09:00
parent fd8257e194
commit 76b68c0ea6
16 changed files with 411 additions and 84 deletions

View File

@ -29,22 +29,22 @@ export const AppBar = () => {
<Button <Button
type="text" type="text"
icon={<ScheduleOutlined size={64} />} icon={<ScheduleOutlined size={64} />}
onClick={() => navigate('/', { replace: true })} onClick={() => navigate('/')}
/> />
<Button <Button
type="text" type="text"
icon={<HeartOutlined />} icon={<HeartOutlined />}
onClick={() => navigate('/fav-players', { replace: true })} onClick={() => navigate('/fav-players')}
/> />
<Button <Button
type="text" type="text"
icon={<SearchOutlined />} icon={<SearchOutlined />}
onClick={() => navigate('/find', { replace: true })} onClick={() => navigate('/find')}
/> />
<Button <Button
type="text" type="text"
icon={<UserOutlined />} icon={<UserOutlined />}
onClick={() => navigate('/user-center', { replace: true })} onClick={() => navigate('/user-center')}
/> />
</Flex> </Flex>
</StyledContainer> </StyledContainer>

View File

@ -3,10 +3,15 @@ import type { IEventInfo } from "../types";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import { EyeOutlined } from "@ant-design/icons"; import { BellFilled, BellOutlined, EyeOutlined } from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import type { TimerType } from "antd/lib/statistic/Timer"; import type { TimerType } from "antd/lib/statistic/Timer";
import { useLogto } from "@logto/react";
import { useAuthHeaders } from "../hooks/useAuthHeaders";
import { useRequest } from "ahooks";
import { WebScoketContext } from "../context/WebsocketContext";
import { getEventSubKey } from "../utils/common";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@ -61,6 +66,43 @@ export function EventCard(props: EventCardProps) {
setStatisticType(statistic.type); setStatisticType(statistic.type);
// console.debug('format: %s', day.format(statistic.format), statistic); // console.debug('format: %s', day.format(statistic.format), statistic);
}, [getStatisticProps]) }, [getStatisticProps])
const { isAuthenticated } = useLogto();
const { messageSender } = useContext(WebScoketContext);
const headers = useAuthHeaders();
const isSubscried = useRequest(async () => {
return fetch(`/api/subscribe-event/${e.matchId}`, {
headers,
})
.then(res => res.json())
.then(json => json.isSub);
}, { refreshDeps: [headers], debounceWait: 300 })
const subReq = useRequest(async () => {
return fetch(`/api/subscribe-event/${e.matchId}`, {
headers,
method: 'PUT',
});
}, { manual: true, refreshDeps: [e, headers], debounceWait: 300 });
const unSubReq = useRequest(async () => {
return fetch(`/api/subscribe-event/${e.matchId}`, {
headers,
method: 'DELETE',
});
}, { manual: true, refreshDeps: [e, headers], debounceWait: 300 });
const [subLoading, setSubloading] = useState(false);
const handleSub = useCallback(async () => {
setSubloading(true);
await subReq.runAsync();
await isSubscried.runAsync();
messageSender?.('SUB', { topic: getEventSubKey(e.matchId) });
setSubloading(false);
}, [e, subReq, isSubscried]);
const handleUnSub = useCallback(async () => {
setSubloading(true);
await unSubReq.runAsync();
await isSubscried.runAsync();
messageSender?.('UNSUB', { topic: getEventSubKey(e.matchId) });
setSubloading(false);
}, [e, isSubscried, unSubReq]);
useEffect(() => { useEffect(() => {
const timeout = day.toDate().getTime() - Date.now(); const timeout = day.toDate().getTime() - Date.now();
updateMessageFormat(); updateMessageFormat();
@ -69,12 +111,27 @@ export function EventCard(props: EventCardProps) {
}, timeout); }, timeout);
return () => clearTimeout(id); return () => clearTimeout(id);
}, [updateMessageFormat]); }, [updateMessageFormat]);
const isSubBtnDisabled = useMemo(() => {
if (!isAuthenticated) return true;
if (subLoading) return true;
if (e.isFinished && !isSubscried.data) return true;
return false;
}, [isAuthenticated, subLoading, isSubscried]);
return ( return (
<Card <Card
key={e.matchId} key={e.matchId}
title={e.title} title={e.title}
style={{ width: '100%' }} style={{ width: '100%' }}
actions={[ actions={[
// <Button
// type="link"
// loading={subLoading}
// disabled={isSubBtnDisabled}
// icon={isSubscried.data ? <BellFilled style={{ color: 'yellow' }} /> :<BellOutlined />}
// onClick={isSubscried.data ? handleUnSub : handleSub}
// >
// {isSubscried.data ? '取消提醒' : '提醒我'}
// </Button>,
<Button <Button
type="link" type="link"
onClick={handleView} onClick={handleView}

View File

@ -1,12 +1,14 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Flex, Form, InputNumber, Segmented, Switch } from "antd"; import { Button, Flex, Form, InputNumber, Segmented, Space, Switch } from "antd";
import { chunk } from 'lodash'; import { chunk } from 'lodash';
import type { BasePlayer } from "../types"; import type { BasePlayer } from "../types";
import { GroupMember } from "./GroupMember"; import { GroupMember } from "./GroupMember";
import { sneckGroup } from "../utils/common"; import { sneckGroup } from "../utils/common";
import { useLoaderData } from "react-router";
import { SyncOutlined } from "@ant-design/icons";
import { useRequest } from "ahooks";
interface Player extends BasePlayer { interface Player extends BasePlayer {
nowScore?: string;
} }
interface CustomPlayer extends Player { interface CustomPlayer extends Player {
@ -27,6 +29,16 @@ enum OrderScore {
} }
export const GroupingPrediction: React.FC<Props> = props => { export const GroupingPrediction: React.FC<Props> = props => {
const { uidScore } = useLoaderData<{ uidScore: Map<string, string>}>();
const uidScoreRequest = useRequest(async () => {
const uids = props.players?.map(player => player.uid).filter(Boolean);
const data = await fetch(`/api/user/nowScores`, {
method: "POST",
body: JSON.stringify({ uids }),
}).then(res => res.json()).catch(() => ({}));
return new Map(Object.entries(data));
}, { manual: true, refreshDeps: [props.players]});
console.debug('uidScore', uidScore);
const [maxPlayerSize, setMaxPlayerSize] = useState(48); const [maxPlayerSize, setMaxPlayerSize] = useState(48);
const [nowScoreGroup, setNowScoreGroup] = useState( const [nowScoreGroup, setNowScoreGroup] = useState(
props.isPassedGame props.isPassedGame
@ -34,11 +46,14 @@ export const GroupingPrediction: React.FC<Props> = props => {
: OrderScore. : OrderScore.
); );
const refactoredPlayers = useMemo(() => { const refactoredPlayers = useMemo(() => {
return nowScoreGroup === OrderScore. ? props.players?.map(e => ({ return nowScoreGroup === OrderScore. ? props.players?.map(e => {
...e, const nowScore = uidScore.get(e.uid) || uidScoreRequest.data?.get(e.uid);
score: !Number.isNaN(Number(e.nowScore)) ? e.nowScore : e.score, return {
})) : [...props.players ?? []]; ...e,
}, [nowScoreGroup, props.players]); score: !Number.isNaN(Number(nowScore)) ? nowScore : e.score,
}
}) : [...props.players ?? []];
}, [nowScoreGroup, props.players, uidScore, uidScoreRequest]);
const players = useMemo(() => { const players = useMemo(() => {
return (refactoredPlayers as CustomPlayer[]) return (refactoredPlayers as CustomPlayer[])
?.slice(0, maxPlayerSize) ?.slice(0, maxPlayerSize)
@ -70,6 +85,9 @@ export const GroupingPrediction: React.FC<Props> = props => {
return subGroup; return subGroup;
}); });
}, [players, grouped, groupLen, maxPlayerSize]); }, [players, grouped, groupLen, maxPlayerSize]);
const handleSyncUidScore = useCallback(() => {
uidScoreRequest.runAsync();
}, []);
return ( return (
<> <>
<Flex gap={10} wrap> <Flex gap={10} wrap>
@ -97,6 +115,11 @@ export const GroupingPrediction: React.FC<Props> = props => {
OrderScore., OrderScore.,
]} /> ]} />
</Form.Item> </Form.Item>
<Form.Item hidden={uidScore.size > 0}>
<Button loading={uidScoreRequest.loading} onClick={handleSyncUidScore} icon={<SyncOutlined />}>
</Button>
</Form.Item>
</Flex> </Flex>
<Flex gap='middle' wrap align="center" justify="center"> <Flex gap='middle' wrap align="center" justify="center">
<React.Fragment key={'normal'}> <React.Fragment key={'normal'}>

View File

@ -1,4 +1,4 @@
import { Button, Flex, Spin } from "antd"; import { Button, Flex, Spin, Typography } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export function HydrateFallback() { export function HydrateFallback() {
@ -13,7 +13,7 @@ export function HydrateFallback() {
<Flex gap={24} vertical align="center" justify="center" style={{ height: '100vh' }}> <Flex gap={24} vertical align="center" justify="center" style={{ height: '100vh' }}>
<Spin spinning /> <Spin spinning />
{tooLongTime && ( {tooLongTime && (
<Button type="link" onClick={() => window.location.reload()}></Button> <Typography.Text>....</Typography.Text>
)} )}
</Flex> </Flex>
); );

View File

@ -2,6 +2,9 @@ import { Outlet, useNavigation } from "react-router";
import { HydrateFallback } from "../HydrateFallback"; import { HydrateFallback } from "../HydrateFallback";
import { AppBar } from "../AppBar"; import { AppBar } from "../AppBar";
import styled from "styled-components"; import styled from "styled-components";
import { useAuthSocket } from "../../hooks/useAuthSocket";
import { WebScoketContext } from "../../context/WebsocketContext";
import { Alert, Button } from "antd";
const StyledContainer = styled.div` const StyledContainer = styled.div`
padding-bottom: 90px; padding-bottom: 90px;
@ -10,8 +13,13 @@ const StyledContainer = styled.div`
export const AppBarLayout = () => { export const AppBarLayout = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const loading = navigation.state === 'loading'; const loading = navigation.state === 'loading';
const [sender] = useAuthSocket();
return loading ? <HydrateFallback /> : (<StyledContainer> return loading ? <HydrateFallback /> : (<StyledContainer>
<Outlet /> <WebScoketContext.Provider value={{ messageSender: sender }}>
<AppBar /> <Alert.ErrorBoundary>
<Outlet />
</Alert.ErrorBoundary>
<AppBar />
</WebScoketContext.Provider>
</StyledContainer>); </StyledContainer>);
} }

View File

@ -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<WebSocket>(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;
}

View File

@ -0,0 +1,8 @@
import { createContext } from "react";
import type { WsWebSendTopicPayload, WsWebSendTopics } from "../utils/common";
interface MessageSender {
messageSender?: <T extends WsWebSendTopics>(topic: T, data: WsWebSendTopicPayload[T]) => void;
};
export const WebScoketContext = createContext<MessageSender>({});

View File

@ -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(<T extends WsWebSendTopics>(topic: T, data: WsWebSendTopicPayload[T]) => {
result.sendMessage(toWebProcessMessage(topic, data));
}, [result]);
return [sender];
}

View File

@ -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]);
};

View File

@ -23,6 +23,29 @@ const server = Bun.serve({
routes: { routes: {
// Serve index.html for all unmatched routes. // Serve index.html for all unmatched routes.
"/*": index, "/*": 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": { "/api/club/find": {
async GET(req) { async GET(req) {
const searchParams = new URL(req.url).searchParams; const searchParams = new URL(req.url).searchParams;
@ -327,7 +350,11 @@ const server = Bun.serve({
WebSocketService.addConnection(ws); WebSocketService.addConnection(ws);
}, },
message(ws, message) { 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) { close(ws, code, reason) {
console.debug('close ws', code, reason) console.debug('close ws', code, reason)

View File

@ -55,8 +55,8 @@ export const route = createBrowserRouter([
const uidScore = await fetch(`/api/user/nowScores`, { const uidScore = await fetch(`/api/user/nowScores`, {
method: "POST", method: "POST",
body: JSON.stringify({ uids }), body: JSON.stringify({ uids }),
}); }).then(res => res.json()).catch(() => ({}));
return { info, members, uidScore: new Map(Object.entries(await uidScore.json())) }; return { info, members, uidScore: new Map(Object.entries(uidScore)) };
}, },
Component: EventPage, Component: EventPage,
HydrateFallback: () => <HydrateFallback /> HydrateFallback: () => <HydrateFallback />

View File

@ -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 { EventSubscribeService } from "./EventSubscribeService";
import type { WsPaylaod } from "../types"; import type { WsPaylaod } from "../types";
const publicTopics = [ const publicTopics: WsServerSendTopics[] = [
WSTopic.MEMBER_CHANGE, 'MEMBER_CHANGE',
'MY_CLIENT_ONLINE',
'DEBUG_MSG',
'EVENT_MEMBER_CHANGE',
"MSG",
]; ];
type BunServerWebSocket = Bun.ServerWebSocket<WsPaylaod>; type BunServerWebSocket = Bun.ServerWebSocket<WsPaylaod>;
@ -23,24 +27,27 @@ export class WebSocketService {
} }
this.#userClients.get(user.sub)?.add(ws); this.#userClients.get(user.sub)?.add(ws);
await this.#initSubscribe(ws); await this.#initSubscribe(ws);
const message = toCustomMessage(WSTopic.MEMBER_CHANGE, this.#userClients.size);
if (isNewMember) { if (isNewMember) {
this.broadcast(WSTopic.MEMBER_CHANGE, message); this.broadcast('MEMBER_CHANGE', this.#userClients.size ?? 0);
} else {
ws.send(message);
} }
this.userClientsBroadcast( this.userClientsBroadcast(
ws.data.user.sub, ws.data.user.sub,
WSTopic.MY_CLIENT_ONLINE, 'MY_CLIENT_ONLINE',
toCustomMessage(WSTopic.MY_CLIENT_ONLINE, this.#userClients.get(user.sub)?.size), 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) { static async #initSubscribe(ws: BunServerWebSocket) {
const user = ws.data.user const user = ws.data.user;
const subEvets = await EventSubscribeService.getEvents(user.sub).then(e => e.map(v => `event:${v}`)); 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, publicTopics);
this.userSub(ws, user.sub, subEvets); this.userSub(ws, user.sub, subEvets);
this.userSub(ws, user.sub, [`MSG:${user.sub}`]);
} }
static publish(ws: BunServerWebSocket, topic: string, message: string, withSelf?: boolean) { 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) { static async userClientsBroadcast<T extends WsServerSendTopics>(user: string, topic: T, data: WsServerSendTopicPayload[T]) {
this.#userClients.get(user)?.forEach(ws => ws.send(message)); this.#userClients.get(user)?.forEach(ws => {
if (!ws.isSubscribed(topic)) return;
ws.send(JSON.stringify({ topic, data }));
});
} }
static removeConnection(ws: BunServerWebSocket) { static removeConnection(ws: BunServerWebSocket) {
@ -60,12 +70,12 @@ export class WebSocketService {
this.#userClients.get(ws.data.user.sub)?.delete(ws); this.#userClients.get(ws.data.user.sub)?.delete(ws);
if (this.#userClients.get(ws.data.user.sub)?.size === 0) { if (this.#userClients.get(ws.data.user.sub)?.size === 0) {
this.#userClients.delete(ws.data.user.sub); 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( this.userClientsBroadcast(
ws.data.user.sub, ws.data.user.sub,
WSTopic.MY_CLIENT_ONLINE, 'MY_CLIENT_ONLINE',
toCustomMessage(WSTopic.MY_CLIENT_ONLINE, this.#userClients.get(ws.data.user.sub)?.size), this.#userClients.get(ws.data.user.sub)?.size ?? 0,
); );
} }
@ -74,17 +84,33 @@ export class WebSocketService {
this.#userSubTopics.set(user, new Set<string>()); this.#userSubTopics.set(user, new Set<string>());
} }
if (!topics.length) return; if (!topics.length) return;
console.debug('User %s subscribe keys: %s', user, topics.join(','));
topics.forEach(topic => { topics.forEach(topic => {
this.#userSubTopics.get(user)?.add(topic); this.#userSubTopics.get(user)?.add(topic);
ws.subscribe(topic); ws.subscribe(topic);
}); });
} }
static broadcast(topic: string, message: string) { static userNotificate<T extends WsServerSendTopics>(user: string, topic: T, data: WsTopicPayload[T]) {
this.#connections.values().forEach(con => { const connections = this.#userClients.get(user);
if (!connections?.size) return;
connections.forEach(con => {
if (con.isSubscribed(topic)) { if (con.isSubscribed(topic)) {
con.send(message); con.sendText(toWebProcessMessage(topic, data));
}
});
}
static broadcast<T extends WsServerSendTopics>(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; if (!action) return;
console.debug('Recieve action: %s', action, message); console.debug('Recieve action: %s', action, message);
switch (action) { switch (action) {
case WSTopic.SUB: case 'SUB': {
ws.subscribe(data.topic); 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; 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; 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; break;
}
default: default:
this.broadcast("Test", 'This is a broadcast message. Everyone should recieved.');
break; break;
} }
} }

42
src/sw.ts Normal file
View File

@ -0,0 +1,42 @@
/// <reference lib="webworker" />
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);
}
})
);
});

View File

@ -87,29 +87,72 @@ export function calculate(winerScore: number, loserScore: number) {
} }
} }
export function toCustomMessage(clientTopic: string | number, data: any) { export type WsServerSendTopicPayload = {
return JSON.stringify({ topic: clientTopic, data }); 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 { export type WsServerSendTopics = keyof WsServerSendTopicPayload;
UNKONOW = 'UNKONW',
MY_CLIENT_ONLINE = 'CLIENT_ONLINE', export type WsWebSendTopicPayload = {
MEMBER_CHANGE = 'MEMBER_CHNAGE', UNKONOW: undefined;
SUB = "SUB", SUB: { topic: string };
UNSUB = "UNSUB", UNSUB: { topic: string };
SEND = "PUBLISH", 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<T extends WsTopics>(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): { export function fromCustomMessage(message: string): {
clientTopic: WSTopic; clientTopic: WsWebSendTopics;
data?: any; data?: WsWebSendTopicPayload[WsWebSendTopics];
} { } {
try { try {
const { topic: clientTopic, data } = JSON.parse(message); const { topic: clientTopic, data } = JSON.parse(message);
return { clientTopic, data }; return { clientTopic, data };
} catch(e) { } catch(e) {
return { 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}`,
};
}
}

View File

@ -1,4 +1,5 @@
export const LOGTO_RESOURCE = 'https://tt.ksr.la'; export const LOGTO_RESOURCE = 'https://tt.ksr.la';
export const CLUB_SELECTOR_KEY = 'CLUB_SELECTOR'; export const CLUB_SELECTOR_KEY = 'CLUB_SELECTOR';
export const STORE_PAGE_LIST_KEY = 'events-page-keys'; export const STORE_PAGE_LIST_KEY = 'events-page-keys';
export const MATCH_RESULT_MAP_KEY = 'match-result-map'; export const MATCH_RESULT_MAP_KEY = 'match-result-map';
export const EVENT_WS_MESSAGE = 'EVENT_WS_MESSAGE';