my-kaiqiuwang/src/services/WebsocketService.ts
kyuuseiryuu c70aeda412 feat(auth): add Kaiqiu account binding and update WebSocket architecture
- Add `UserBind` model to Prisma schema for linking Logto and Kaiqiu user IDs.
- Implement `/api/account/bind` endpoint (GET/PUT) to handle Kaiqiu account binding and retrieval.
- Refactor `KaiqiuService` to include a `login` method that performs browser automation-like login on kaiqiuwang.cc using `cheerio` and `fetch`.
- Update `UserCenter` page to include a new `BindKaiqiuAccount` component.
- Restructure `WebSocketService`:
  - Change from a simple global connection map to a per-user client tracking system (`#userClients`).
  - Update topic naming conventions (e.g., `ONLINE_MEMBER_CHANGE` -> `MEMBER_CHANGE`).
  - Add client-side broadcast capabilities for user-specific events like `MY_CLIENT_ONLINE`.
  - Add support for dynamic subscription handling (SUB/UNSUB) via WebSocket messages.
- Update `verifyLogtoToken` to accept either `Headers` or a raw token string for flexibility in WebSocket auth.
- Minor fixes: typo corrections in `WSTopic` enum and commented out debug logs.

BREAKING CHANGE: WebSocket payload structure has changed.
The `ws.data` property now contains a `WsPaylaod` object with a `user` field (previously it was a JSON string of the JWT payload).
The `WSTopic` names have been updated (e.g., `ONLINE_MEMBER_CHANGE` is now `MEMBER_CHANGE`), requiring updates to any client code subscribing to these topics.
2026-03-18 01:18:34 +09:00

111 lines
3.7 KiB
TypeScript

import { fromCustomMessage, toCustomMessage, WSTopic } from "../utils/common";
import { EventSubscribeService } from "./EventSubscribeService";
import type { WsPaylaod } from "../types";
const publicTopics = [
WSTopic.MEMBER_CHANGE,
];
type BunServerWebSocket = Bun.ServerWebSocket<WsPaylaod>;
export class WebSocketService {
static #connections = new Set<BunServerWebSocket>();
static #userSubTopics = new Map<string, Set<string>>();
static #userClients = new Map<string, Set<BunServerWebSocket>>();
static async addConnection(ws: BunServerWebSocket) {
this.#connections.add(ws);
const user = ws.data.user;
let isNewMember = false;
if (!this.#userClients.has(user.sub)) {
this.#userClients.set(user.sub, new Set());
isNewMember = true;
}
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.userClientsBroadcast(
ws.data.user.sub,
WSTopic.MY_CLIENT_ONLINE,
toCustomMessage(WSTopic.MY_CLIENT_ONLINE, this.#userClients.get(user.sub)?.size),
);
}
static async #initSubscribe(ws: BunServerWebSocket) {
const user = ws.data.user
const subEvets = await EventSubscribeService.getEvents(user.sub).then(e => e.map(v => `event:${v}`));
this.userSub(ws, user.sub, publicTopics);
this.userSub(ws, user.sub, subEvets);
}
static publish(ws: BunServerWebSocket, topic: string, message: string, withSelf?: boolean) {
ws.publish(topic, message);
if (withSelf) {
ws.send(message);
}
}
static async userClientsBroadcast(user: string, topic: string, message: string) {
this.#userClients.get(user)?.forEach(ws => ws.send(message));
}
static removeConnection(ws: BunServerWebSocket) {
this.#connections.delete(ws);
console.debug('Someone disconnected. User: ', ws.data.user.sub);
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.userClientsBroadcast(
ws.data.user.sub,
WSTopic.MY_CLIENT_ONLINE,
toCustomMessage(WSTopic.MY_CLIENT_ONLINE, this.#userClients.get(ws.data.user.sub)?.size),
);
}
static userSub(ws: BunServerWebSocket, user: string, topics: string[]) {
if (!this.#userSubTopics.has(user)) {
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 => {
if (con.isSubscribed(topic)) {
con.send(message);
}
});
}
static processMessage(ws: BunServerWebSocket, message: string | Buffer<ArrayBuffer>) {
const { clientTopic: action, data } = fromCustomMessage(message.toString());
if (!action) return;
console.debug('Recieve action: %s', action, message);
switch (action) {
case WSTopic.SUB:
ws.subscribe(data.topic);
break;
case WSTopic.UNSUB:
ws.unsubscribe(data.topic);
break;
case WSTopic.SEND:
ws.publish(data.topic, data.message);
break;
default:
this.broadcast("Test", 'This is a broadcast message. Everyone should recieved.');
break;
}
}
}