fix: 优化网页端不能连接的问题
This commit is contained in:
@@ -7,7 +7,12 @@ import { WebSocketServer } from 'ws';
|
|||||||
const PORT = Number(process.env.PORT || 8088);
|
const PORT = Number(process.env.PORT || 8088);
|
||||||
const PATH = process.env.WS_PATH || '/ws/signal';
|
const PATH = process.env.WS_PATH || '/ws/signal';
|
||||||
|
|
||||||
const wss = new WebSocketServer({ port: PORT, path: PATH });
|
const wss = new WebSocketServer({
|
||||||
|
port: PORT,
|
||||||
|
path: PATH,
|
||||||
|
// WebRTC SDP 消息可能较大,将最大负载设为 1MB(默认 100MB 但在某些代理环境下受限)
|
||||||
|
maxPayload: 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
// deviceId -> { ws, type }
|
// deviceId -> { ws, type }
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
@@ -17,10 +22,34 @@ const pending = new Map();
|
|||||||
const keyOf = (a, b) => `${a}->${b}`;
|
const keyOf = (a, b) => `${a}->${b}`;
|
||||||
|
|
||||||
function send(ws, obj) {
|
function send(ws, obj) {
|
||||||
if (ws && ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
|
if (ws && ws.readyState === ws.OPEN) {
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify(obj));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[send] 发送消息失败:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— 服务端主动 ping/pong 保活:每 30 秒向所有连接发送 ping,
|
||||||
|
// 若 60 秒内未收到 pong 则强制关闭连接(防止僵死连接占用资源)。
|
||||||
|
const HEARTBEAT_INTERVAL = 30_000;
|
||||||
|
const heartbeatTimer = setInterval(() => {
|
||||||
|
wss.clients.forEach((ws) => {
|
||||||
|
if (ws._alive === false) {
|
||||||
|
console.warn('[heartbeat] 连接无响应,强制关闭');
|
||||||
|
return ws.terminate();
|
||||||
|
}
|
||||||
|
ws._alive = false;
|
||||||
|
try { ws.ping(); } catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
}, HEARTBEAT_INTERVAL);
|
||||||
|
wss.on('close', () => clearInterval(heartbeatTimer));
|
||||||
|
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws) => {
|
||||||
|
ws._alive = true;
|
||||||
|
ws.on('pong', () => { ws._alive = true; });
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
let msg;
|
let msg;
|
||||||
try {
|
try {
|
||||||
@@ -29,6 +58,8 @@ wss.on('connection', (ws) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const type = (msg.type || '').toUpperCase();
|
const type = (msg.type || '').toUpperCase();
|
||||||
|
// 忽略客户端心跳 ping,仅用于保活
|
||||||
|
if (type === 'PING') return;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'REGISTER': handleRegister(ws, msg); break;
|
case 'REGISTER': handleRegister(ws, msg); break;
|
||||||
case 'DEVICE_LIST': handleDeviceList(ws); break;
|
case 'DEVICE_LIST': handleDeviceList(ws); break;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export class SignalingClient {
|
|||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
this.register();
|
this.register();
|
||||||
this.onConnected && this.onConnected();
|
this.onConnected && this.onConnected();
|
||||||
|
// 启动心跳保活:每 25 秒发送 ping,防止中间代理或服务器因空闲超时断开连接
|
||||||
|
this._startHeartbeat();
|
||||||
};
|
};
|
||||||
this.ws.onmessage = (ev) => {
|
this.ws.onmessage = (ev) => {
|
||||||
let msg;
|
let msg;
|
||||||
@@ -28,13 +30,38 @@ export class SignalingClient {
|
|||||||
}
|
}
|
||||||
this.onMessage && this.onMessage(msg);
|
this.onMessage && this.onMessage(msg);
|
||||||
};
|
};
|
||||||
this.ws.onclose = () => this.onDisconnected && this.onDisconnected();
|
this.ws.onclose = (ev) => {
|
||||||
this.ws.onerror = (e) => this.onError && this.onError(e?.message || 'WebSocket 错误');
|
// 记录关闭码和原因,便于诊断断连根因:
|
||||||
|
// 1000 = 正常关闭, 1001 = 离开, 1006 = 异常断开(无 close frame)
|
||||||
|
// 1009 = 消息过大, 1011 = 服务端异常
|
||||||
|
console.warn('[信令] WebSocket 已关闭 code=%d reason=%s wasClean=%s',
|
||||||
|
ev.code, ev.reason || '(无)', ev.wasClean);
|
||||||
|
this._stopHeartbeat();
|
||||||
|
this.onDisconnected && this.onDisconnected(ev.code, ev.reason);
|
||||||
|
};
|
||||||
|
this.ws.onerror = (e) => {
|
||||||
|
console.error('[信令] WebSocket 错误', e);
|
||||||
|
this.onError && this.onError(e?.message || 'WebSocket 错误');
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.onError && this.onError(e.message);
|
this.onError && this.onError(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_startHeartbeat() {
|
||||||
|
this._stopHeartbeat();
|
||||||
|
this._heartbeatTimer = setInterval(() => {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
// 发送轻量级 ping 消息,保持连接活跃(服务端会忽略未知类型消息)
|
||||||
|
try { this.ws.send(JSON.stringify({ type: 'PING' })); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopHeartbeat() {
|
||||||
|
if (this._heartbeatTimer) { clearInterval(this._heartbeatTimer); this._heartbeatTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
register() {
|
register() {
|
||||||
this.send({
|
this.send({
|
||||||
type: 'REGISTER',
|
type: 'REGISTER',
|
||||||
@@ -79,6 +106,7 @@ export class SignalingClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
|
this._stopHeartbeat();
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.ws.close();
|
this.ws.close();
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export const DEFAULT_ICE_SERVERS = [
|
|||||||
// { urls: 'turn:192.168.100.224:3478?transport=tcp', username: 'tt', credential: 'fht' },
|
// { urls: 'turn:192.168.100.224:3478?transport=tcp', username: 'tt', credential: 'fht' },
|
||||||
{ urls: 'stun:175.178.213.60:3478' },
|
{ urls: 'stun:175.178.213.60:3478' },
|
||||||
// { urls: 'stun:192.168.5.224:3478' },
|
// { urls: 'stun:192.168.5.224:3478' },
|
||||||
// { urls: 'stun:stun.l.google.com:19302' },
|
// { urls: 'stun:stun.l.google.com:19302' },
|
||||||
// { urls: 'stun:stun1.l.google.com:19302' },
|
// { urls: 'stun:stun1.l.google.com:19302' },
|
||||||
// { urls: 'stun:stun2.l.google.com:19302' },
|
// { urls: 'stun:stun2.l.google.com:19302' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const store = reactive({
|
export const store = reactive({
|
||||||
@@ -128,21 +128,30 @@ export async function connectToDevice(targetId) {
|
|||||||
|
|
||||||
if (webrtc) { await webrtc.close(); webrtc = null; }
|
if (webrtc) { await webrtc.close(); webrtc = null; }
|
||||||
|
|
||||||
webrtc = new WebRtcController({
|
try {
|
||||||
iceServers: store.iceServers,
|
webrtc = new WebRtcController({
|
||||||
deviceId: store.deviceId,
|
iceServers: store.iceServers,
|
||||||
targetDeviceId: targetId,
|
deviceId: store.deviceId,
|
||||||
signaling,
|
targetDeviceId: targetId,
|
||||||
onIceState: (s) => {
|
signaling,
|
||||||
if (s === 'connected') { store.rtcConnected = true; store.statusText = '已连接,可远程控制'; }
|
onIceState: (s) => {
|
||||||
else if (s === 'disconnected' || s === 'failed') { store.rtcConnected = false; store.statusText = '连接已断开'; }
|
if (s === 'connected') { store.rtcConnected = true; store.statusText = '已连接,可远程控制'; }
|
||||||
},
|
else if (s === 'disconnected' || s === 'failed') { store.rtcConnected = false; store.statusText = '连接已断开'; }
|
||||||
onDataChannelState: (open) => { store.dataChannelOpen = open; },
|
},
|
||||||
onStream: (stream) => { store.remoteStream = markRaw(stream); },
|
onDataChannelState: (open) => { store.dataChannelOpen = open; },
|
||||||
onStats: (stats) => { store.stats = stats; },
|
onStream: (stream) => { store.remoteStream = markRaw(stream); },
|
||||||
onError: (msg) => { if (msg) store.error = msg; },
|
onStats: (stats) => { store.stats = stats; },
|
||||||
});
|
onError: (msg) => { if (msg) store.error = msg; },
|
||||||
await webrtc.createOffer();
|
});
|
||||||
|
await webrtc.createOffer();
|
||||||
|
} catch (e) {
|
||||||
|
// 捕获 createOffer 阶段的所有异常,避免未处理的 Promise 拒绝导致浏览器中断脚本执行、
|
||||||
|
// 间接影响 WebSocket 信令连接的存活。
|
||||||
|
console.error('[connectToDevice] 创建 Offer 失败:', e);
|
||||||
|
store.error = '创建连接失败: ' + (e?.message || e);
|
||||||
|
store.statusText = '连接失败';
|
||||||
|
if (webrtc) { await webrtc.close(); webrtc = null; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function disconnectDevice() {
|
export async function disconnectDevice() {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.ttstd.signaling.config;
|
package com.ttstd.signaling.config;
|
||||||
|
|
||||||
import com.ttstd.signaling.handler.SignalWebSocketHandler;
|
import com.ttstd.signaling.handler.SignalWebSocketHandler;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||||
|
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSocket
|
@EnableWebSocket
|
||||||
@@ -16,6 +18,21 @@ public class WebSocketConfig implements WebSocketConfigurer {
|
|||||||
this.signalWebSocketHandler = signalWebSocketHandler;
|
this.signalWebSocketHandler = signalWebSocketHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置 WebSocket 容器的消息大小限制与会话超时。
|
||||||
|
* <p>默认文本消息缓冲区为 8KB,WebRTC SDP(OFFER/ANSWER)消息经 JSON 包装后
|
||||||
|
* 可能超过该限制,导致服务端抛出 TextMessageLimitException 并关闭连接。
|
||||||
|
* 此处将文本消息上限设为 512KB,二进制消息上限设为 512KB,会话空闲超时设为 10 分钟。
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public ServletServerContainerFactoryBean createWebSocketContainer() {
|
||||||
|
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
|
||||||
|
container.setMaxTextMessageBufferSize(512 * 1024); // 512 KB
|
||||||
|
container.setMaxBinaryMessageBufferSize(512 * 1024); // 512 KB
|
||||||
|
container.setMaxSessionIdleTimeout(600_000L); // 10 分钟
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
registry.addHandler(signalWebSocketHandler, "/ws/signal")
|
registry.addHandler(signalWebSocketHandler, "/ws/signal")
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
|||||||
case "DEVICE_LIST":
|
case "DEVICE_LIST":
|
||||||
handleDeviceList(session, signalMessage);
|
handleDeviceList(session, signalMessage);
|
||||||
break;
|
break;
|
||||||
|
case "PING":
|
||||||
|
// 客户端心跳保活消息,无需处理,仅用于防止中间代理因空闲超时断开连接
|
||||||
|
break;
|
||||||
case "OFFER":
|
case "OFFER":
|
||||||
// 连接请求:统一经 ConnectionRequestManager 做校验/去重/待确认跟踪后再转发
|
// 连接请求:统一经 ConnectionRequestManager 做校验/去重/待确认跟踪后再转发
|
||||||
handleConnectionRequest(signalMessage);
|
handleConnectionRequest(signalMessage);
|
||||||
|
|||||||
Reference in New Issue
Block a user