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
|
<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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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'}>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>);
|
||||||
}
|
}
|
||||||
@ -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: {
|
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)
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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
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) {
|
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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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';
|
||||||
Loading…
Reference in New Issue
Block a user