Files
CamTalk/frontend/src/lib/websocket.ts
2026-06-14 18:10:58 +08:00

183 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// WebSocket 连接管理
// 职责:心跳保活、指数退避重连、类型安全的消息收发
// 来源docs/03-接口文档.md §六 连接管理
// ============================================================
import type { ClientMessage, ServerMessage, WsMessage } from "../types";
// Electron 类型声明(通过 preload 脚本注入)
interface ElectronAPI {
getBackendUrl: () => string;
isElectron: boolean;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
}
}
// WebSocket 地址优先级:
// 1. Electron preload 注入的地址(桌面端)
// 2. Vite 环境变量
// 3. 根据当前页面地址自动推导(浏览器端)
function getWsUrl(): string {
// Electron 环境:使用 preload 注入的地址
if (window.electronAPI?.isElectron) {
return window.electronAPI.getBackendUrl();
}
// Vite 环境变量
if (import.meta.env.VITE_WS_URL) {
return import.meta.env.VITE_WS_URL;
}
// 浏览器端:根据当前页面地址推导
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host || "localhost:8080";
return `${protocol}//${host}/ws`;
}
const WS_URL = getWsUrl();
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;
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);
}
/** 建立连接 */
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN) return;
this.shouldReconnect = true;
this.setStatus("connecting");
const ws = new WebSocket(WS_URL);
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++;
this.connect();
}, 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();