206 lines
6.5 KiB
TypeScript
206 lines
6.5 KiB
TypeScript
|
|
import { WebSocketServer, WebSocket } from "ws";
|
|
import { Logger } from "@nestjs/common";
|
|
import { WebSocketServerConfig, WSClient, WebSocketType } from "../types";
|
|
import { SocketMessage } from '@nice/common';
|
|
|
|
const DEFAULT_CONFIG: WebSocketServerConfig = {
|
|
pingInterval: 30000,
|
|
pingTimeout: 5000,
|
|
debug: false, // 新增默认调试配置
|
|
};
|
|
interface IWebSocketServer {
|
|
start(): Promise<void>;
|
|
stop(): Promise<void>;
|
|
broadcast(data: any): void;
|
|
handleConnection(ws: WSClient): void;
|
|
handleDisconnection(ws: WSClient): void;
|
|
}
|
|
|
|
export abstract class BaseWebSocketServer implements IWebSocketServer {
|
|
private _wss: WebSocketServer | null = null;
|
|
protected clients: Set<WSClient> = new Set();
|
|
protected timeouts: Map<WSClient, NodeJS.Timeout> = new Map();
|
|
protected pingIntervalId?: NodeJS.Timeout;
|
|
protected readonly logger = new Logger(this.constructor.name);
|
|
protected readonly finalConfig: WebSocketServerConfig;
|
|
private userClientMap: Map<string, WSClient> = new Map();
|
|
constructor(
|
|
protected readonly config: Partial<WebSocketServerConfig> = {}
|
|
) {
|
|
this.finalConfig = {
|
|
...DEFAULT_CONFIG,
|
|
...config,
|
|
};
|
|
}
|
|
protected debugLog(message: string, ...optionalParams: any[]): void {
|
|
if (this.finalConfig.debug) {
|
|
this.logger.debug(message, ...optionalParams);
|
|
}
|
|
}
|
|
public getClientCount() {
|
|
return this.clients.size
|
|
}
|
|
// 暴露 WebSocketServer 实例的只读访问
|
|
public get wss(): WebSocketServer | null {
|
|
return this._wss;
|
|
}
|
|
|
|
// 内部使用的 setter
|
|
protected set wss(value: WebSocketServer | null) {
|
|
this._wss = value;
|
|
}
|
|
|
|
public abstract get serverType(): WebSocketType;
|
|
|
|
public get serverPath(): string {
|
|
return this.finalConfig.path || `/${this.serverType}`;
|
|
}
|
|
|
|
public async start(): Promise<void> {
|
|
if (this._wss) await this.stop();
|
|
|
|
this._wss = new WebSocketServer({
|
|
noServer: true,
|
|
path: this.serverPath
|
|
});
|
|
|
|
this.debugLog(`WebSocket server starting on path: ${this.serverPath}`);
|
|
this.setupServerEvents();
|
|
this.startPingInterval();
|
|
}
|
|
|
|
public async stop(): Promise<void> {
|
|
if (this.pingIntervalId) {
|
|
clearInterval(this.pingIntervalId);
|
|
this.pingIntervalId = undefined;
|
|
}
|
|
|
|
this.clients.forEach(client => client.close());
|
|
this.clients.clear();
|
|
this.timeouts.clear();
|
|
|
|
if (this._wss) {
|
|
await new Promise(resolve => this._wss!.close(resolve));
|
|
this._wss = null;
|
|
}
|
|
|
|
this.debugLog(`WebSocket server stopped on path: ${this.serverPath}`);
|
|
}
|
|
|
|
public broadcast(data: SocketMessage): void {
|
|
this.clients.forEach(client =>
|
|
client.readyState === WebSocket.OPEN && client.send(JSON.stringify(data))
|
|
);
|
|
}
|
|
public sendToUser(id: string, data: SocketMessage) {
|
|
const message = JSON.stringify(data);
|
|
const client = this.userClientMap.get(id);
|
|
client?.send(message)
|
|
}
|
|
public sendToUsers(ids: string[], data: SocketMessage) {
|
|
const message = JSON.stringify(data);
|
|
ids.forEach(id => {
|
|
const client = this.userClientMap.get(id);
|
|
client?.send(message);
|
|
});
|
|
}
|
|
public sendToRoom(roomId: string, data: SocketMessage) {
|
|
const message = JSON.stringify(data);
|
|
this.clients.forEach(client => {
|
|
if (client.readyState === WebSocket.OPEN && client.roomId === roomId) {
|
|
client.send(message)
|
|
}
|
|
})
|
|
}
|
|
protected getRoomClientsCount(roomId?: string): number {
|
|
if (!roomId) return 0;
|
|
return Array.from(this.clients).filter(client => client.roomId === roomId).length;
|
|
}
|
|
|
|
public handleConnection(ws: WSClient): void {
|
|
if (ws.userId) {
|
|
this.userClientMap.set(ws.userId, ws);
|
|
}
|
|
ws.isAlive = true;
|
|
ws.type = this.serverType;
|
|
this.clients.add(ws);
|
|
this.setupClientEvents(ws);
|
|
|
|
const roomClientsCount = this.getRoomClientsCount(ws.roomId);
|
|
this.debugLog(`
|
|
[${this.serverType}] connected
|
|
userId ${ws.userId}
|
|
roomId ${ws.roomId}
|
|
room clients ${roomClientsCount}
|
|
total clients ${this.clients.size}`);
|
|
}
|
|
|
|
public handleDisconnection(ws: WSClient): void {
|
|
if (ws.userId) {
|
|
this.userClientMap.delete(ws.userId);
|
|
}
|
|
this.clients.delete(ws);
|
|
const timeout = this.timeouts.get(ws);
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
this.timeouts.delete(ws);
|
|
}
|
|
ws.terminate();
|
|
|
|
const roomClientsCount = this.getRoomClientsCount(ws.roomId);
|
|
|
|
this.debugLog(`
|
|
[${this.serverType}] disconnected
|
|
userId ${ws.userId}
|
|
roomId ${ws.roomId}
|
|
room clients ${roomClientsCount}
|
|
total clients ${this.clients.size}`);
|
|
}
|
|
protected setupClientEvents(ws: WSClient): void {
|
|
ws.on('pong', () => this.handlePong(ws))
|
|
.on('close', () => this.handleDisconnection(ws))
|
|
.on('error', (error) => {
|
|
this.logger.error(`[${this.serverType}] client error on path ${this.serverPath}:`, error);
|
|
this.handleDisconnection(ws);
|
|
});
|
|
}
|
|
|
|
private handlePong(ws: WSClient): void {
|
|
ws.isAlive = true;
|
|
const timeout = this.timeouts.get(ws);
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
this.timeouts.delete(ws);
|
|
}
|
|
}
|
|
|
|
private startPingInterval(): void {
|
|
this.pingIntervalId = setInterval(
|
|
() => this.pingClients(),
|
|
this.finalConfig.pingInterval
|
|
);
|
|
}
|
|
|
|
private pingClients(): void {
|
|
this.clients.forEach(ws => {
|
|
if (!ws.isAlive) return this.handleDisconnection(ws);
|
|
|
|
ws.isAlive = false;
|
|
ws.ping();
|
|
const timeout = setTimeout(
|
|
() => !ws.isAlive && this.handleDisconnection(ws),
|
|
this.finalConfig.pingTimeout
|
|
);
|
|
this.timeouts.set(ws, timeout);
|
|
});
|
|
}
|
|
|
|
protected setupServerEvents(): void {
|
|
if (!this._wss) return;
|
|
this._wss
|
|
.on('connection', (ws: WSClient) => this.handleConnection(ws))
|
|
.on('error', (error) => this.logger.error(`Server error on path ${this.serverPath}:`, error));
|
|
}
|
|
}
|