2026-06-12 17:46:25 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
// WebSocket 连接管理
|
|
|
|
|
|
// 职责:心跳保活、指数退避重连、类型安全的消息收发
|
|
|
|
|
|
// 来源:docs/03-接口文档.md §六 连接管理
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
import type { ClientMessage, ServerMessage, WsMessage } from "../types";
|
|
|
|
|
|
|
2026-06-14 11:55:07 +08:00
|
|
|
|
// WebSocket 地址:优先使用环境变量,否则基于当前页面地址自动推导
|
|
|
|
|
|
const WS_URL =
|
|
|
|
|
|
import.meta.env.VITE_WS_URL ||
|
|
|
|
|
|
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
|
2026-06-12 17:46:25 +08:00
|
|
|
|
const PING_INTERVAL = 30_000; // 30 秒心跳
|
|
|
|
|
|
const MAX_RECONNECT_DELAY = 30_000; // 最大重连延迟 30 秒
|
|
|
|
|
|
|
|
|
|
|
|
type MessageHandler = (msg: ServerMessage) => void;
|
|
|
|
|
|
type StatusHandler = (status: ConnectionStatus) => void;
|
|
|
|
|
|
|
|
|
|
|
|
export type ConnectionStatus = "connecting" | "connected" | "disconnected";
|
|
|
|
|
|
|
|
|
|
|
|
export class CamTalkWebSocket {
|
|
|
|
|
|
private ws: WebSocket | null = null;
|
|
|
|
|
|
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
|
private reconnectAttempt = 0;
|
|
|
|
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
private shouldReconnect = true;
|
2026-06-14 18:38:45 +08:00
|
|
|
|
private token: string | undefined;
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
|
|
|
|
|
private messageHandlers = new Set<MessageHandler>();
|
|
|
|
|
|
private statusHandlers = new Set<StatusHandler>();
|
|
|
|
|
|
private _status: ConnectionStatus = "disconnected";
|
|
|
|
|
|
|
|
|
|
|
|
get status(): ConnectionStatus {
|
|
|
|
|
|
return this._status;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 注册消息回调 */
|
|
|
|
|
|
onMessage(handler: MessageHandler): () => void {
|
|
|
|
|
|
this.messageHandlers.add(handler);
|
|
|
|
|
|
return () => this.messageHandlers.delete(handler);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 注册连接状态回调 */
|
|
|
|
|
|
onStatusChange(handler: StatusHandler): () => void {
|
|
|
|
|
|
this.statusHandlers.add(handler);
|
|
|
|
|
|
return () => this.statusHandlers.delete(handler);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 18:38:45 +08:00
|
|
|
|
/** 建立连接,可选传入 JWT token 用于认证 */
|
|
|
|
|
|
connect(token?: string): void {
|
2026-06-12 17:46:25 +08:00
|
|
|
|
if (this.ws?.readyState === WebSocket.OPEN) return;
|
|
|
|
|
|
|
2026-06-14 18:38:45 +08:00
|
|
|
|
this.token = token;
|
2026-06-12 17:46:25 +08:00
|
|
|
|
this.shouldReconnect = true;
|
|
|
|
|
|
this.setStatus("connecting");
|
|
|
|
|
|
|
2026-06-14 18:38:45 +08:00
|
|
|
|
const url = token ? `${WS_URL}?token=${encodeURIComponent(token)}` : WS_URL;
|
|
|
|
|
|
const ws = new WebSocket(url);
|
2026-06-12 17:46:25 +08:00
|
|
|
|
|
|
|
|
|
|
ws.onopen = () => {
|
|
|
|
|
|
this.reconnectAttempt = 0;
|
|
|
|
|
|
this.setStatus("connected");
|
|
|
|
|
|
this.startPing();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
ws.onmessage = (event) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const msg = JSON.parse(event.data) as WsMessage;
|
|
|
|
|
|
// 忽略 pong,心跳由服务端自动回复
|
|
|
|
|
|
if (msg.type === "pong") return;
|
|
|
|
|
|
this.messageHandlers.forEach((h) => h(msg as ServerMessage));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
console.error("[WS] 无法解析消息:", event.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
ws.onclose = () => {
|
|
|
|
|
|
this.stopPing();
|
|
|
|
|
|
this.setStatus("disconnected");
|
|
|
|
|
|
if (this.shouldReconnect) {
|
|
|
|
|
|
this.scheduleReconnect();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
ws.onerror = (err) => {
|
|
|
|
|
|
console.error("[WS] 连接错误:", err);
|
|
|
|
|
|
ws.close();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
this.ws = ws;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 断开连接,不再自动重连 */
|
|
|
|
|
|
disconnect(): void {
|
|
|
|
|
|
this.shouldReconnect = false;
|
|
|
|
|
|
this.clearTimers();
|
|
|
|
|
|
this.ws?.close();
|
|
|
|
|
|
this.ws = null;
|
|
|
|
|
|
this.setStatus("disconnected");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 发送客户端消息 */
|
|
|
|
|
|
send(msg: ClientMessage): void {
|
|
|
|
|
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
|
|
|
|
console.warn("[WS] 连接未就绪,消息丢弃:", msg.type);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.ws.send(JSON.stringify(msg));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 发送 ping 心跳 */
|
|
|
|
|
|
private startPing(): void {
|
|
|
|
|
|
this.stopPing();
|
|
|
|
|
|
this.pingTimer = setInterval(() => {
|
|
|
|
|
|
this.send({ type: "ping" });
|
|
|
|
|
|
}, PING_INTERVAL);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private stopPing(): void {
|
|
|
|
|
|
if (this.pingTimer) {
|
|
|
|
|
|
clearInterval(this.pingTimer);
|
|
|
|
|
|
this.pingTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 指数退避 + 抖动重连 */
|
|
|
|
|
|
private scheduleReconnect(): void {
|
|
|
|
|
|
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), MAX_RECONNECT_DELAY);
|
|
|
|
|
|
const jitter = Math.random() * 1000;
|
|
|
|
|
|
const totalDelay = delay + jitter;
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[WS] ${totalDelay.toFixed(0)}ms 后重连 (attempt ${this.reconnectAttempt})`);
|
|
|
|
|
|
|
|
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
|
|
|
|
this.reconnectAttempt++;
|
2026-06-14 18:38:45 +08:00
|
|
|
|
this.connect(this.token);
|
2026-06-12 17:46:25 +08:00
|
|
|
|
}, totalDelay);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private setStatus(status: ConnectionStatus): void {
|
|
|
|
|
|
this._status = status;
|
|
|
|
|
|
this.statusHandlers.forEach((h) => h(status));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private clearTimers(): void {
|
|
|
|
|
|
this.stopPing();
|
|
|
|
|
|
if (this.reconnectTimer) {
|
|
|
|
|
|
clearTimeout(this.reconnectTimer);
|
|
|
|
|
|
this.reconnectTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 创建单例实例 */
|
|
|
|
|
|
export const wsClient = new CamTalkWebSocket();
|