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:
parent
fd8257e194
commit
76b68c0ea6
@ -29,22 +29,22 @@ export const AppBar = () => {
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ScheduleOutlined size={64} />}
|
||||
onClick={() => navigate('/', { replace: true })}
|
||||
onClick={() => navigate('/')}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<HeartOutlined />}
|
||||
onClick={() => navigate('/fav-players', { replace: true })}
|
||||
onClick={() => navigate('/fav-players')}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={() => navigate('/find', { replace: true })}
|
||||
onClick={() => navigate('/find')}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<UserOutlined />}
|
||||
onClick={() => navigate('/user-center', { replace: true })}
|
||||
onClick={() => navigate('/user-center')}
|
||||
/>
|
||||
</Flex>
|
||||
</StyledContainer>
|
||||
|
||||
@ -3,10 +3,15 @@ import type { IEventInfo } from "../types";
|
||||
import dayjs from "dayjs";
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import { EyeOutlined } from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { BellFilled, BellOutlined, EyeOutlined } from "@ant-design/icons";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
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(timezone);
|
||||
@ -61,6 +66,43 @@ export function EventCard(props: EventCardProps) {
|
||||
setStatisticType(statistic.type);
|
||||
// console.debug('format: %s', day.format(statistic.format), statistic);
|
||||
}, [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(() => {
|
||||
const timeout = day.toDate().getTime() - Date.now();
|
||||
updateMessageFormat();
|
||||
@ -69,12 +111,27 @@ export function EventCard(props: EventCardProps) {
|
||||
}, timeout);
|
||||
return () => clearTimeout(id);
|
||||
}, [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 (
|
||||
<Card
|
||||
key={e.matchId}
|
||||
title={e.title}
|
||||
style={{ width: '100%' }}
|
||||
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
|
||||
type="link"
|
||||
onClick={handleView}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Flex, Form, InputNumber, Segmented, Switch } from "antd";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Flex, Form, InputNumber, Segmented, Space, Switch } from "antd";
|
||||
import { chunk } from 'lodash';
|
||||
import type { BasePlayer } from "../types";
|
||||
import { GroupMember } from "./GroupMember";
|
||||
import { sneckGroup } from "../utils/common";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { SyncOutlined } from "@ant-design/icons";
|
||||
import { useRequest } from "ahooks";
|
||||
|
||||
interface Player extends BasePlayer {
|
||||
nowScore?: string;
|
||||
}
|
||||
|
||||
interface CustomPlayer extends Player {
|
||||
@ -27,6 +29,16 @@ enum OrderScore {
|
||||
}
|
||||
|
||||
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 [nowScoreGroup, setNowScoreGroup] = useState(
|
||||
props.isPassedGame
|
||||
@ -34,11 +46,14 @@ export const GroupingPrediction: React.FC<Props> = props => {
|
||||
: OrderScore.当前积分
|
||||
);
|
||||
const refactoredPlayers = useMemo(() => {
|
||||
return nowScoreGroup === OrderScore.当前积分 ? props.players?.map(e => ({
|
||||
return nowScoreGroup === OrderScore.当前积分 ? props.players?.map(e => {
|
||||
const nowScore = uidScore.get(e.uid) || uidScoreRequest.data?.get(e.uid);
|
||||
return {
|
||||
...e,
|
||||
score: !Number.isNaN(Number(e.nowScore)) ? e.nowScore : e.score,
|
||||
})) : [...props.players ?? []];
|
||||
}, [nowScoreGroup, props.players]);
|
||||
score: !Number.isNaN(Number(nowScore)) ? nowScore : e.score,
|
||||
}
|
||||
}) : [...props.players ?? []];
|
||||
}, [nowScoreGroup, props.players, uidScore, uidScoreRequest]);
|
||||
const players = useMemo(() => {
|
||||
return (refactoredPlayers as CustomPlayer[])
|
||||
?.slice(0, maxPlayerSize)
|
||||
@ -70,6 +85,9 @@ export const GroupingPrediction: React.FC<Props> = props => {
|
||||
return subGroup;
|
||||
});
|
||||
}, [players, grouped, groupLen, maxPlayerSize]);
|
||||
const handleSyncUidScore = useCallback(() => {
|
||||
uidScoreRequest.runAsync();
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<Flex gap={10} wrap>
|
||||
@ -97,6 +115,11 @@ export const GroupingPrediction: React.FC<Props> = props => {
|
||||
OrderScore.年度积分,
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item hidden={uidScore.size > 0}>
|
||||
<Button loading={uidScoreRequest.loading} onClick={handleSyncUidScore} icon={<SyncOutlined />}>
|
||||
刷新当前积分
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
<Flex gap='middle' wrap align="center" justify="center">
|
||||
<React.Fragment key={'normal'}>
|
||||
|
||||
@ -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() {
|
||||
<Flex gap={24} vertical align="center" justify="center" style={{ height: '100vh' }}>
|
||||
<Spin spinning />
|
||||
{tooLongTime && (
|
||||
<Button type="link" onClick={() => window.location.reload()}>等的太久了,刷新试试?</Button>
|
||||
<Typography.Text>桥豆麻袋,正在努力加载数据中....</Typography.Text>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@ -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 ? <HydrateFallback /> : (<StyledContainer>
|
||||
<WebScoketContext.Provider value={{ messageSender: sender }}>
|
||||
<Alert.ErrorBoundary>
|
||||
<Outlet />
|
||||
</Alert.ErrorBoundary>
|
||||
<AppBar />
|
||||
</WebScoketContext.Provider>
|
||||
</StyledContainer>);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
8
src/context/WebsocketContext.tsx
Normal file
8
src/context/WebsocketContext.tsx
Normal 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>({});
|
||||
35
src/hooks/useAuthSocket.ts
Normal file
35
src/hooks/useAuthSocket.ts
Normal 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];
|
||||
}
|
||||
63
src/hooks/useHandlerServerMessage.tsx
Normal file
63
src/hooks/useHandlerServerMessage.tsx
Normal 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]);
|
||||
};
|
||||
@ -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) {
|
||||
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)
|
||||
|
||||
@ -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: () => <HydrateFallback />
|
||||
|
||||
@ -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<WsPaylaod>;
|
||||
@ -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<T extends WsServerSendTopics>(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<string>());
|
||||
}
|
||||
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<T extends WsServerSendTopics>(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<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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
42
src/sw.ts
Normal file
42
src/sw.ts
Normal 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);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
@ -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<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): {
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -2,3 +2,4 @@ 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';
|
||||
export const EVENT_WS_MESSAGE = 'EVENT_WS_MESSAGE';
|
||||
Loading…
Reference in New Issue
Block a user