【m】增加多用户连接
This commit is contained in:
@@ -174,6 +174,10 @@ class UIRenderer {
|
|||||||
// 通话结束 - 渲染通话结束界面
|
// 通话结束 - 渲染通话结束界面
|
||||||
this.renderCallEnded();
|
this.renderCallEnded();
|
||||||
break;
|
break;
|
||||||
|
case 'PARTICIPANT_LEFT':
|
||||||
|
// participant离开 - 更新UI但房间仍然存在
|
||||||
|
this.renderParticipantLeft(changes.connectionId);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,6 +896,19 @@ class UIRenderer {
|
|||||||
window.location.href = './endcall/endcall.html';
|
window.location.href = './endcall/endcall.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 渲染participant离开(host端,房间仍然存在)
|
||||||
|
renderParticipantLeft(connectionId) {
|
||||||
|
console.log(`Participant left: ${connectionId}, updating UI`);
|
||||||
|
// 更新远程用户状态显示为离线
|
||||||
|
if (this.elements.remoteNetworkIndicator) {
|
||||||
|
this.elements.remoteNetworkIndicator.className = 'w-2 h-2 bg-gray-500 rounded-full';
|
||||||
|
}
|
||||||
|
// 显示断开连接的遮罩层(如果存在)
|
||||||
|
if (this.elements.disconnectedOverlay) {
|
||||||
|
this.elements.disconnectedOverlay.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取状态文本
|
// 获取状态文本
|
||||||
getStatusText(status) {
|
getStatusText(status) {
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
|
|||||||
@@ -8,15 +8,9 @@ import { RenderStreaming } from "../../module/renderstreaming.js"; // WebRTC连
|
|||||||
import { getServerConfig, getRTCConfiguration } from "../js/config.js";//服务器配置和RTC配置
|
import { getServerConfig, getRTCConfiguration } from "../js/config.js";//服务器配置和RTC配置
|
||||||
import { showNotification, generateId } from './utils.js'; // 导入通知函数
|
import { showNotification, generateId } from './utils.js'; // 导入通知函数
|
||||||
import chatMessage from './chatmessage.js';
|
import chatMessage from './chatmessage.js';
|
||||||
// 默认视频流尺寸
|
|
||||||
const defaultStreamWidth = 1280;
|
|
||||||
const defaultStreamHeight = 720;
|
|
||||||
|
|
||||||
class CallStateManager {
|
class CallStateManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
const renderstreaming = null; // WebRTC连接管理实例
|
|
||||||
const useWebSocket = null; // 是否使用WebSocket信令
|
|
||||||
const connectionId = null; // 连接ID
|
|
||||||
// 核心状态
|
// 核心状态
|
||||||
this.state = {
|
this.state = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -332,9 +326,23 @@ class CallStateManager {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
this.renderstreaming = new RenderStreaming(signaling, config); // 创建WebRTC连接管理实例
|
this.renderstreaming = new RenderStreaming(signaling, config); // 创建WebRTC连接管理实例
|
||||||
|
this.renderstreaming.onNewPeer = (connectionId) => {
|
||||||
|
console.log(`New peer created for ${connectionId}, adding local tracks`);
|
||||||
|
if (this.state.localStream) {
|
||||||
|
const tracks = this.state.localStream.getTracks();
|
||||||
|
for (const track of tracks) {
|
||||||
|
this.renderstreaming.addTransceiver(track, { direction: 'sendonly' });
|
||||||
|
}
|
||||||
|
this.setCodecPreferences();
|
||||||
|
}
|
||||||
|
};
|
||||||
// 连接建立回调
|
// 连接建立回调
|
||||||
this.renderstreaming.onConnect = () => {
|
this.renderstreaming.onConnect = (connectionId, data) => {
|
||||||
|
// 保存角色信息(host/participant)
|
||||||
|
if (data && data.role) {
|
||||||
|
this.role = data.role;
|
||||||
|
console.log(`Connected as ${this.role}`);
|
||||||
|
}
|
||||||
// 连接建立后,更新状态为ongoing
|
// 连接建立后,更新状态为ongoing
|
||||||
this.state.session.status = 'ongoing';
|
this.state.session.status = 'ongoing';
|
||||||
this.notify({ type: 'CALL_STATUS_CHANGE', status: 'ongoing' });
|
this.notify({ type: 'CALL_STATUS_CHANGE', status: 'ongoing' });
|
||||||
@@ -347,21 +355,39 @@ class CallStateManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (this.state.localStream) {
|
if (this.state.localStream) {
|
||||||
const tracks = this.state.localStream.getTracks(); // 获取本地媒体轨道
|
// const tracks = this.state.localStream.getTracks();
|
||||||
for (const track of tracks) {
|
// for (const track of tracks) {
|
||||||
this.renderstreaming.addTransceiver(track, { direction: 'sendonly' }); // 添加发送轨道
|
// this.renderstreaming.addTransceiver(track, { direction: 'sendonly' });
|
||||||
}
|
// }
|
||||||
this.setCodecPreferences(); // 设置编解码器偏好
|
// this.setCodecPreferences();
|
||||||
this.showStatsMessage(); // 显示统计信息
|
this.showStatsMessage();
|
||||||
} else {
|
} else {
|
||||||
console.error('Local stream is not available');
|
console.error('Local stream is not available');
|
||||||
showNotification('本地视频流不可用', 'error');
|
showNotification('本地视频流不可用', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 连接断开回调
|
// 连接断开回调(收到服务器的 disconnect 消息,通常是 host 离开导致房间关闭)
|
||||||
this.renderstreaming.onDisconnect = () => {
|
this.renderstreaming.onDisconnect = () => {
|
||||||
this.hangUp(); // 挂断连接
|
console.log('Received disconnect from server, host left or room closed');
|
||||||
|
this.hangUp(); // 房间已关闭,挂断连接
|
||||||
|
};
|
||||||
|
|
||||||
|
// participant离开回调(host收到,房间仍然存在)
|
||||||
|
this.renderstreaming.onParticipantLeft = (connectionId) => {
|
||||||
|
console.log(`Participant left: ${connectionId}, room still active`);
|
||||||
|
// 更新远程用户状态,但不关闭房间
|
||||||
|
this.updateRemoteUserStatus('offline');
|
||||||
|
this.updateRemoteUserNetworkQuality('no_signal');
|
||||||
|
showNotification('对方已离开通话', 'warning');
|
||||||
|
// 清理远端流,重置Peer连接为新participant加入做准备
|
||||||
|
if (this.state.remoteStream) {
|
||||||
|
this.state.remoteStream.getTracks().forEach(track => track.stop());
|
||||||
|
this.state.remoteStream = null;
|
||||||
|
}
|
||||||
|
this.notify({ type: 'REMOTE_STREAM_OBTAINED', stream: null });
|
||||||
|
// 通知UI更新
|
||||||
|
this.notify({ type: 'PARTICIPANT_LEFT', connectionId: connectionId });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 轨道事件回调
|
// 轨道事件回调
|
||||||
@@ -473,6 +499,8 @@ class CallStateManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 挂断WebRTC连接
|
* 挂断WebRTC连接
|
||||||
|
* Host挂断:房间删除,通知所有participants
|
||||||
|
* Participant挂断:仅自己离开,房间保留
|
||||||
* @async
|
* @async
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
@@ -484,11 +512,17 @@ class CallStateManager {
|
|||||||
clearInterval(this.durationInterval);
|
clearInterval(this.durationInterval);
|
||||||
this.durationInterval = null;
|
this.durationInterval = null;
|
||||||
}
|
}
|
||||||
console.log(`Disconnect peer on ${this.connectionId}.`);
|
|
||||||
|
const isHost = this.role === 'host';
|
||||||
|
console.log(`Disconnect peer on ${this.connectionId}. Role: ${this.role}`);
|
||||||
|
|
||||||
// 删除连接并停止WebRTC
|
// 删除连接并停止WebRTC
|
||||||
if (this.renderstreaming) {
|
if (this.renderstreaming) {
|
||||||
try {
|
try {
|
||||||
|
// 发送断开连接信令给服务器
|
||||||
|
// 服务器会根据角色决定:
|
||||||
|
// - host断开:通知所有participants,删除房间
|
||||||
|
// - participant断开:仅通知host,保留房间
|
||||||
await this.renderstreaming.deleteConnection();
|
await this.renderstreaming.deleteConnection();
|
||||||
await this.renderstreaming.stop();
|
await this.renderstreaming.stop();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -501,8 +535,9 @@ class CallStateManager {
|
|||||||
this.updateRemoteUserStatus('offline');
|
this.updateRemoteUserStatus('offline');
|
||||||
this.updateRemoteUserNetworkQuality('no_signal');
|
this.updateRemoteUserNetworkQuality('no_signal');
|
||||||
this.connectionId = null;
|
this.connectionId = null;
|
||||||
|
this.role = null;
|
||||||
this.state.session.status = 'ended';
|
this.state.session.status = 'ended';
|
||||||
this.notify({ type: 'CALL_ENDED' });
|
this.notify({ type: 'CALL_ENDED', reason: isHost ? 'host_hangup' : 'participant_hangup' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -570,18 +605,14 @@ class CallStateManager {
|
|||||||
this.state.session.remoteUser.networkQuality = networkQuality;
|
this.state.session.remoteUser.networkQuality = networkQuality;
|
||||||
this.notify({ type: 'REMOTE_MEDIA_CHANGE', localUser: this.state.session.localUser, remoteUser: this.state.session.remoteUser });
|
this.notify({ type: 'REMOTE_MEDIA_CHANGE', localUser: this.state.session.localUser, remoteUser: this.state.session.remoteUser });
|
||||||
}
|
}
|
||||||
// 结束通话
|
// 结束通话(用户主动点击挂断按钮)
|
||||||
endCall() {
|
async endCall() {
|
||||||
if (this.durationInterval) {
|
console.log(`endCall called. Role: ${this.role}`);
|
||||||
clearInterval(this.durationInterval);
|
// 调用 hangUp() 正确关闭 WebRTC 连接并发送断开信令
|
||||||
this.durationInterval = null;
|
// hangUp 内部会根据角色区分:
|
||||||
}
|
// - host: 通知所有participants,删除房间
|
||||||
this.state.session.status = 'ended';
|
// - participant: 仅自己离开,房间保留
|
||||||
this.notify({ type: 'CALL_ENDED' });
|
await this.hangUp();
|
||||||
|
|
||||||
// 发送结束通话请求到服务器
|
|
||||||
// [API_CALL: POST /api/call/:callId/leave]
|
|
||||||
// [WEBSOCKET_EMIT: leave-call]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加入通话
|
// 加入通话
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default class Peer extends EventTarget {
|
|||||||
|
|
||||||
this.pc.oniceconnectionstatechange = () => {
|
this.pc.oniceconnectionstatechange = () => {
|
||||||
_this.log(`iceConnectionState changed:${_this.pc.iceConnectionState}`);
|
_this.log(`iceConnectionState changed:${_this.pc.iceConnectionState}`);
|
||||||
if (_this.pc.iceConnectionState === 'disconnected') {
|
if (_this.pc.iceConnectionState === 'failed') {
|
||||||
this.dispatchEvent(new Event('disconnect'));
|
this.dispatchEvent(new Event('disconnect'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export class RenderStreaming {
|
|||||||
this.onTrackEvent = function (data) { Logger.log(`OnTrack event peer with data:${data}`); };
|
this.onTrackEvent = function (data) { Logger.log(`OnTrack event peer with data:${data}`); };
|
||||||
this.onAddChannel = function (data) { Logger.log(`onAddChannel event peer with data:${data}`); };
|
this.onAddChannel = function (data) { Logger.log(`onAddChannel event peer with data:${data}`); };
|
||||||
this.onMessage = function (data) { Logger.log(`On message: ${data}`); };
|
this.onMessage = function (data) { Logger.log(`On message: ${data}`); };
|
||||||
|
this.onParticipantLeft = function (connectionId) { Logger.log(`Participant left on ${connectionId}.`); };
|
||||||
|
this.onNewPeer = function (connectionId) { Logger.log(`New peer created for ${connectionId}.`); };
|
||||||
this._config = config;
|
this._config = config;
|
||||||
this._signaling = signaling;
|
this._signaling = signaling;
|
||||||
this._signaling.addEventListener('connect', this._onConnect.bind(this));
|
this._signaling.addEventListener('connect', this._onConnect.bind(this));
|
||||||
@@ -31,13 +33,14 @@ export class RenderStreaming {
|
|||||||
this._signaling.addEventListener('answer', this._onAnswer.bind(this));
|
this._signaling.addEventListener('answer', this._onAnswer.bind(this));
|
||||||
this._signaling.addEventListener('candidate', this._onIceCandidate.bind(this));
|
this._signaling.addEventListener('candidate', this._onIceCandidate.bind(this));
|
||||||
this._signaling.addEventListener('on-message', this._onMessage.bind(this));
|
this._signaling.addEventListener('on-message', this._onMessage.bind(this));
|
||||||
|
this._signaling.addEventListener('participant-left', this._onParticipantLeft.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
async _onConnect(e) {
|
async _onConnect(e) {
|
||||||
const data = e.detail;
|
const data = e.detail;
|
||||||
if (this._connectionId == data.connectionId) {
|
if (this._connectionId == data.connectionId) {
|
||||||
this._preparePeerConnection(this._connectionId, data.polite);
|
this._preparePeerConnection(this._connectionId, data.polite);
|
||||||
this.onConnect(data.connectionId);
|
this.onConnect(data.connectionId, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +57,12 @@ export class RenderStreaming {
|
|||||||
|
|
||||||
async _onOffer(e) {
|
async _onOffer(e) {
|
||||||
const offer = e.detail;
|
const offer = e.detail;
|
||||||
|
// 如果已有Peer但ICE连接已断开,需要重建Peer
|
||||||
|
if (this._peer && this._peer.pc && this._peer.pc.iceConnectionState === 'disconnected') {
|
||||||
|
Logger.log('ICE disconnected, resetting PeerConnection for new offer');
|
||||||
|
this._peer.close();
|
||||||
|
this._peer = null;
|
||||||
|
}
|
||||||
if (!this._peer) {
|
if (!this._peer) {
|
||||||
this._preparePeerConnection(offer.connectionId, offer.polite);
|
this._preparePeerConnection(offer.connectionId, offer.polite);
|
||||||
}
|
}
|
||||||
@@ -91,6 +100,12 @@ export class RenderStreaming {
|
|||||||
const data = e.detail;
|
const data = e.detail;
|
||||||
this.onMessage(data);
|
this.onMessage(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _onParticipantLeft(e) {
|
||||||
|
const data = e.detail;
|
||||||
|
Logger.log(`Participant left: ${data.connectionId}`);
|
||||||
|
this.onParticipantLeft(data.connectionId);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* if not set argument, a generated uuid is used.
|
* if not set argument, a generated uuid is used.
|
||||||
* @param {string | null} connectionId
|
* @param {string | null} connectionId
|
||||||
@@ -114,9 +129,9 @@ export class RenderStreaming {
|
|||||||
|
|
||||||
// Create peerConnection with proxy server and set up handlers
|
// Create peerConnection with proxy server and set up handlers
|
||||||
this._peer = new Peer(connectionId, polite, this._config);
|
this._peer = new Peer(connectionId, polite, this._config);
|
||||||
this._peer.addEventListener('disconnect', () => {
|
// this._peer.addEventListener('disconnect', () => {
|
||||||
this.onDisconnect(`Receive disconnect message from peer. connectionId:${connectionId}`);
|
// this.onDisconnect(`Receive disconnect message from peer. connectionId:${connectionId}`);
|
||||||
});
|
// });
|
||||||
this._peer.addEventListener('trackevent', (e) => {
|
this._peer.addEventListener('trackevent', (e) => {
|
||||||
const data = e.detail;
|
const data = e.detail;
|
||||||
this.onTrackEvent(data);
|
this.onTrackEvent(data);
|
||||||
@@ -145,6 +160,7 @@ export class RenderStreaming {
|
|||||||
const candidate = e.detail;
|
const candidate = e.detail;
|
||||||
this._signaling.sendCandidate(candidate.connectionId, candidate.candidate, candidate.sdpMid, candidate.sdpMLineIndex);
|
this._signaling.sendCandidate(candidate.connectionId, candidate.candidate, candidate.sdpMid, candidate.sdpMLineIndex);
|
||||||
});
|
});
|
||||||
|
this.onNewPeer(connectionId);
|
||||||
return this._peer;
|
return this._peer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ export class WebSocketSignaling extends EventTarget {
|
|||||||
case "on-message":
|
case "on-message":
|
||||||
this.dispatchEvent(new CustomEvent('on-message', { detail: msg.data }));
|
this.dispatchEvent(new CustomEvent('on-message', { detail: msg.data }));
|
||||||
break;
|
break;
|
||||||
|
case "participant-left":
|
||||||
|
this.dispatchEvent(new CustomEvent('participant-left', { detail: msg }));
|
||||||
|
break;
|
||||||
case "broadcast":
|
case "broadcast":
|
||||||
// 处理服务器广播的消息
|
// 处理服务器广播的消息
|
||||||
this.dispatchEvent(new CustomEvent('on-message', { detail: msg.message }));
|
this.dispatchEvent(new CustomEvent('on-message', { detail: msg.message }));
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"test": "jest --colors test/*.ts",
|
"test": "jest --colors test/*.ts",
|
||||||
"newman": "newman run test/renderstreaming.postman_collection.json",
|
"newman": "newman run test/renderstreaming.postman_collection.json",
|
||||||
"start": "node ./build/index.js -s -p 8080 -m private -k ./server.key -c ./server.cert",
|
"start": "node ./build/index.js -s -p 8080 -m private -k ./server.key -c ./server.cert",
|
||||||
"dev": "ts-node ./src/index.ts -s -p 8080",
|
"dev": "ts-node ./src/index.ts -s -p 8080 -m private",
|
||||||
"lint": "eslint src/**/*.ts test/**/*.ts",
|
"lint": "eslint src/**/*.ts test/**/*.ts",
|
||||||
"pack": "pkg ."
|
"pack": "pkg ."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,11 +19,21 @@ let isPrivate: boolean;
|
|||||||
const clients: Map<WebSocket, Set<string>> = new Map<WebSocket, Set<string>>();
|
const clients: Map<WebSocket, Set<string>> = new Map<WebSocket, Set<string>>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 连接对映射
|
* 连接组结构
|
||||||
* 键: connectionId
|
* host: 主机WebSocket实例(第一个连接的客户端)
|
||||||
* 值: [WebSocket实例1, WebSocket实例2]
|
* participants: 参与者WebSocket集合(后续连接的客户端)
|
||||||
*/
|
*/
|
||||||
const connectionPair: Map<string, [WebSocket, WebSocket]> = new Map<string, [WebSocket, WebSocket]>();
|
interface ConnectionGroup {
|
||||||
|
host: WebSocket;
|
||||||
|
participants: Set<WebSocket>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接组映射
|
||||||
|
* 键: connectionId
|
||||||
|
* 值: ConnectionGroup(1个host + 多个participants)
|
||||||
|
*/
|
||||||
|
const connectionGroup: Map<string, ConnectionGroup> = new Map<string, ConnectionGroup>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取或创建WebSocket会话的连接ID集合
|
* 获取或创建WebSocket会话的连接ID集合
|
||||||
@@ -66,6 +76,37 @@ function add(ws: WebSocket): void {
|
|||||||
console.log(`Add WebSocket: ${id}`);
|
console.log(`Add WebSocket: ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断WebSocket是否为指定连接组的host
|
||||||
|
* @param ws WebSocket连接实例
|
||||||
|
* @param connectionId 连接ID
|
||||||
|
* @returns 是否为host
|
||||||
|
*/
|
||||||
|
function isHost(ws: WebSocket, connectionId: string): boolean {
|
||||||
|
const group = connectionGroup.get(connectionId);
|
||||||
|
return group != null && group.host === ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向连接组中除发送者外的所有成员发送消息
|
||||||
|
* @param connectionId 连接ID
|
||||||
|
* @param senderWs 发送者WebSocket实例
|
||||||
|
* @param message 要发送的消息对象
|
||||||
|
*/
|
||||||
|
function broadcastToGroup(connectionId: string, senderWs: WebSocket, message: any): void {
|
||||||
|
const group = connectionGroup.get(connectionId);
|
||||||
|
if (!group) return;
|
||||||
|
// 如果发送者是host,转发给所有participants
|
||||||
|
if (senderWs === group.host) {
|
||||||
|
group.participants.forEach(participantWs => {
|
||||||
|
participantWs.send(JSON.stringify(message));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 如果发送者是participant,转发给host
|
||||||
|
group.host.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除WebSocket连接
|
* 移除WebSocket连接
|
||||||
* @param ws WebSocket连接实例
|
* @param ws WebSocket连接实例
|
||||||
@@ -73,20 +114,25 @@ function add(ws: WebSocket): void {
|
|||||||
function remove(ws: WebSocket): void {
|
function remove(ws: WebSocket): void {
|
||||||
// 获取连接的所有连接ID
|
// 获取连接的所有连接ID
|
||||||
const connectionIds = clients.get(ws);
|
const connectionIds = clients.get(ws);
|
||||||
|
if (!connectionIds) return;
|
||||||
|
|
||||||
// 遍历所有连接ID
|
// 遍历所有连接ID
|
||||||
connectionIds.forEach(connectionId => {
|
connectionIds.forEach(connectionId => {
|
||||||
// 获取连接对
|
const group = connectionGroup.get(connectionId);
|
||||||
const pair = connectionPair.get(connectionId);
|
if (group) {
|
||||||
if (pair) {
|
if (group.host === ws) {
|
||||||
// 找到另一个WebSocket实例
|
// host断开连接,通知所有participants房间已关闭
|
||||||
const otherSessionWs = pair[0] == ws ? pair[1] : pair[0];
|
group.participants.forEach(participantWs => {
|
||||||
if (otherSessionWs) {
|
participantWs.send(JSON.stringify({ type: "disconnect", connectionId: connectionId, reason: "host-left" }));
|
||||||
// 向另一个连接发送断开连接消息
|
});
|
||||||
otherSessionWs.send(JSON.stringify({ type: "disconnect", connectionId: connectionId }));
|
// 删除整个连接组
|
||||||
|
connectionGroup.delete(connectionId);
|
||||||
|
} else {
|
||||||
|
// participant断开连接,从participants中移除并通知host
|
||||||
|
group.participants.delete(ws);
|
||||||
|
group.host.send(JSON.stringify({ type: "participant-left", connectionId: connectionId }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 从连接对映射中删除
|
|
||||||
connectionPair.delete(connectionId);
|
|
||||||
// 记录删除连接ID的日志
|
// 记录删除连接ID的日志
|
||||||
console.log(`Remove connectionId: ${connectionId}`);
|
console.log(`Remove connectionId: ${connectionId}`);
|
||||||
});
|
});
|
||||||
@@ -96,7 +142,8 @@ function remove(ws: WebSocket): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理连接请求
|
* 处理连接请求(1对多模式)
|
||||||
|
* 第一个连接的客户端成为host,后续连接的客户端成为participants
|
||||||
* @param ws WebSocket连接实例
|
* @param ws WebSocket连接实例
|
||||||
* @param connectionId 连接ID
|
* @param connectionId 连接ID
|
||||||
*/
|
*/
|
||||||
@@ -104,21 +151,16 @@ function onConnect(ws: WebSocket, connectionId: string): void {
|
|||||||
let polite = true;
|
let polite = true;
|
||||||
// 处理私有模式
|
// 处理私有模式
|
||||||
if (isPrivate) {
|
if (isPrivate) {
|
||||||
if (connectionPair.has(connectionId)) {
|
if (connectionGroup.has(connectionId)) {
|
||||||
const pair = connectionPair.get(connectionId);
|
const group = connectionGroup.get(connectionId);
|
||||||
|
// 已有host,新连接作为participant加入
|
||||||
if (pair[0] != null && pair[1] != null) {
|
group.participants.add(ws);
|
||||||
// 连接ID已被使用
|
console.log(`Participant joined connectionId: ${connectionId}, total participants: ${group.participants.size}`);
|
||||||
ws.send(JSON.stringify({ type: "error", message: `${connectionId}: This connection id is already used.` }));
|
|
||||||
return;
|
|
||||||
} else if (pair[0] != null) {
|
|
||||||
// 找到配对连接
|
|
||||||
connectionPair.set(connectionId, [pair[0], ws]);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 创建新的连接对
|
// 第一个连接成为host
|
||||||
connectionPair.set(connectionId, [ws, null]);
|
connectionGroup.set(connectionId, { host: ws, participants: new Set<WebSocket>() });
|
||||||
polite = false;
|
polite = false;
|
||||||
|
console.log(`Host created connectionId: ${connectionId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,35 +168,44 @@ function onConnect(ws: WebSocket, connectionId: string): void {
|
|||||||
const connectionIds = getOrCreateConnectionIds(ws);
|
const connectionIds = getOrCreateConnectionIds(ws);
|
||||||
// 添加连接ID
|
// 添加连接ID
|
||||||
connectionIds.add(connectionId);
|
connectionIds.add(connectionId);
|
||||||
// 发送连接成功消息
|
// 发送连接成功消息(包含角色信息)
|
||||||
ws.send(JSON.stringify({ type: "connect", connectionId: connectionId, polite: polite }));
|
const role = polite ? 'participant' : 'host';
|
||||||
|
ws.send(JSON.stringify({ type: "connect", connectionId: connectionId, polite: polite, role: role }));
|
||||||
//启用心跳包
|
//启用心跳包
|
||||||
//AddHeartbeat(ws, connectionId);
|
//AddHeartbeat(ws, connectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理断开连接请求
|
* 处理断开连接请求(1对多模式)
|
||||||
* @param ws WebSocket连接实例
|
* @param ws WebSocket连接实例
|
||||||
* @param connectionId 连接ID
|
* @param connectionId 连接ID
|
||||||
*/
|
*/
|
||||||
function onDisconnect(ws: WebSocket, connectionId: string): void {
|
function onDisconnect(ws: WebSocket, connectionId: string): void {
|
||||||
// 获取连接的连接ID集合
|
// 获取连接的连接ID集合
|
||||||
const connectionIds = clients.get(ws);
|
const connectionIds = clients.get(ws);
|
||||||
|
if (connectionIds) {
|
||||||
// 从集合中删除连接ID
|
// 从集合中删除连接ID
|
||||||
connectionIds.delete(connectionId);
|
connectionIds.delete(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
// 处理连接对
|
// 处理连接组
|
||||||
if (connectionPair.has(connectionId)) {
|
const group = connectionGroup.get(connectionId);
|
||||||
const pair = connectionPair.get(connectionId);
|
if (group) {
|
||||||
// 找到另一个WebSocket实例
|
if (group.host === ws) {
|
||||||
const otherSessionWs = pair[0] == ws ? pair[1] : pair[0];
|
// host断开连接,通知所有participants房间已关闭,并删除连接组
|
||||||
if (otherSessionWs) {
|
group.participants.forEach(participantWs => {
|
||||||
// 向另一个连接发送断开连接消息
|
participantWs.send(JSON.stringify({ type: "disconnect", connectionId: connectionId, reason: "host-left" }));
|
||||||
otherSessionWs.send(JSON.stringify({ type: "disconnect", connectionId: connectionId }));
|
});
|
||||||
|
connectionGroup.delete(connectionId);
|
||||||
|
console.log(`Host disconnected, room ${connectionId} deleted, notified ${group.participants.size} participants`);
|
||||||
|
} else {
|
||||||
|
// participant断开连接,从组中移除并通知host(使用participant-left类型,host不会关闭房间)
|
||||||
|
group.participants.delete(ws);
|
||||||
|
group.host.send(JSON.stringify({ type: "participant-left", connectionId: connectionId }));
|
||||||
|
console.log(`Participant left connectionId: ${connectionId}, remaining participants: ${group.participants.size}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 从连接对映射中删除
|
|
||||||
connectionPair.delete(connectionId);
|
|
||||||
// 向当前连接发送断开连接消息
|
// 向当前连接发送断开连接消息
|
||||||
ws.send(JSON.stringify({ type: "disconnect", connectionId: connectionId }));
|
ws.send(JSON.stringify({ type: "disconnect", connectionId: connectionId }));
|
||||||
//RemoveHeartbeat(ws);
|
//RemoveHeartbeat(ws);
|
||||||
@@ -163,7 +214,8 @@ function onDisconnect(ws: WebSocket, connectionId: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理offer信令
|
* 处理offer信令(1对多模式)
|
||||||
|
* host的offer转发给所有participants,participant的offer转发给host
|
||||||
* @param ws WebSocket连接实例
|
* @param ws WebSocket连接实例
|
||||||
* @param message 消息数据
|
* @param message 消息数据
|
||||||
*/
|
*/
|
||||||
@@ -175,22 +227,27 @@ function onOffer(ws: WebSocket, message: any): void {
|
|||||||
|
|
||||||
// 处理私有模式
|
// 处理私有模式
|
||||||
if (isPrivate) {
|
if (isPrivate) {
|
||||||
if (connectionPair.has(connectionId)) {
|
if (connectionGroup.has(connectionId)) {
|
||||||
const pair = connectionPair.get(connectionId);
|
const group = connectionGroup.get(connectionId);
|
||||||
// 找到另一个WebSocket实例
|
if (group.host === ws) {
|
||||||
const otherSessionWs = pair[0] == ws ? pair[1] : pair[0];
|
// host发送offer,转发给所有participants
|
||||||
if (otherSessionWs) {
|
|
||||||
// 设置为polite模式
|
|
||||||
newOffer.polite = true;
|
newOffer.polite = true;
|
||||||
// 发送offer消息
|
group.participants.forEach(participantWs => {
|
||||||
otherSessionWs.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer }));
|
participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer }));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// participant发送offer,转发给host
|
||||||
|
newOffer.polite = true;
|
||||||
|
group.host.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 公共模式:创建新的连接对
|
// 公共模式:创建新的连接组(如果不存在)
|
||||||
connectionPair.set(connectionId, [ws, null]);
|
if (!connectionGroup.has(connectionId)) {
|
||||||
|
connectionGroup.set(connectionId, { host: ws, participants: new Set<WebSocket>() });
|
||||||
|
}
|
||||||
// 向所有其他客户端广播offer
|
// 向所有其他客户端广播offer
|
||||||
clients.forEach((_v, k) => {
|
clients.forEach((_v, k) => {
|
||||||
if (k == ws) {
|
if (k == ws) {
|
||||||
@@ -201,7 +258,8 @@ function onOffer(ws: WebSocket, message: any): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理answer信令
|
* 处理answer信令(1对多模式)
|
||||||
|
* participant的answer转发给host
|
||||||
* @param ws WebSocket连接实例
|
* @param ws WebSocket连接实例
|
||||||
* @param message 消息数据
|
* @param message 消息数据
|
||||||
*/
|
*/
|
||||||
@@ -215,27 +273,27 @@ function onAnswer(ws: WebSocket, message: any): void {
|
|||||||
// 创建新的answer
|
// 创建新的answer
|
||||||
const newAnswer = new Answer(message.sdp, Date.now());
|
const newAnswer = new Answer(message.sdp, Date.now());
|
||||||
|
|
||||||
// 检查连接对是否存在
|
// 检查连接组是否存在
|
||||||
if (!connectionPair.has(connectionId)) {
|
if (!connectionGroup.has(connectionId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取连接对
|
const group = connectionGroup.get(connectionId);
|
||||||
const pair = connectionPair.get(connectionId);
|
|
||||||
// 找到另一个WebSocket实例
|
|
||||||
const otherSessionWs = pair[0] == ws ? pair[1] : pair[0];
|
|
||||||
|
|
||||||
// 公共模式:更新连接对
|
if (group.host === ws) {
|
||||||
if (!isPrivate) {
|
// host发送answer,转发给所有participants(通常host不发送answer)
|
||||||
connectionPair.set(connectionId, [otherSessionWs, ws]);
|
group.participants.forEach(participantWs => {
|
||||||
|
participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "answer", data: newAnswer }));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// participant发送answer,转发给host
|
||||||
|
group.host.send(JSON.stringify({ from: connectionId, to: "", type: "answer", data: newAnswer }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送answer消息
|
|
||||||
otherSessionWs.send(JSON.stringify({ from: connectionId, to: "", type: "answer", data: newAnswer }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理candidate信令
|
* 处理candidate信令(1对多模式)
|
||||||
|
* host的candidate转发给所有participants,participant的candidate转发给host
|
||||||
* @param ws WebSocket连接实例
|
* @param ws WebSocket连接实例
|
||||||
* @param message 消息数据
|
* @param message 消息数据
|
||||||
*/
|
*/
|
||||||
@@ -247,13 +305,16 @@ function onCandidate(ws: WebSocket, message: any): void {
|
|||||||
|
|
||||||
// 处理私有模式
|
// 处理私有模式
|
||||||
if (isPrivate) {
|
if (isPrivate) {
|
||||||
if (connectionPair.has(connectionId)) {
|
if (connectionGroup.has(connectionId)) {
|
||||||
const pair = connectionPair.get(connectionId);
|
const group = connectionGroup.get(connectionId);
|
||||||
// 找到另一个WebSocket实例
|
if (group.host === ws) {
|
||||||
const otherSessionWs = pair[0] == ws ? pair[1] : pair[0];
|
// host发送candidate,转发给所有participants
|
||||||
if (otherSessionWs) {
|
group.participants.forEach(participantWs => {
|
||||||
// 发送candidate消息
|
participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "candidate", data: candidate }));
|
||||||
otherSessionWs.send(JSON.stringify({ from: connectionId, to: "", type: "candidate", data: candidate }));
|
});
|
||||||
|
} else {
|
||||||
|
// participant发送candidate,转发给host
|
||||||
|
group.host.send(JSON.stringify({ from: connectionId, to: "", type: "candidate", data: candidate }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -263,6 +324,15 @@ function onCandidate(ws: WebSocket, message: any): void {
|
|||||||
// 获取连接ID
|
// 获取连接ID
|
||||||
const connectionId = message.connectionId;
|
const connectionId = message.connectionId;
|
||||||
const clientId = message.clientId;
|
const clientId = message.clientId;
|
||||||
|
// 在1对多模式下,通知host有新的呼叫请求
|
||||||
|
if (connectionGroup.has(connectionId)) {
|
||||||
|
const group = connectionGroup.get(connectionId);
|
||||||
|
if (group.host !== ws) {
|
||||||
|
// participant发起呼叫,通知host
|
||||||
|
group.host.send(JSON.stringify({ from: connectionId, to: "", type: "call-request", data: connectionId }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 兼容旧的广播方式
|
||||||
clients.forEach((_v, k) => {
|
clients.forEach((_v, k) => {
|
||||||
if (k === ws) {
|
if (k === ws) {
|
||||||
return;
|
return;
|
||||||
@@ -270,19 +340,13 @@ function onCandidate(ws: WebSocket, message: any): void {
|
|||||||
if (_v == clientId) {
|
if (_v == clientId) {
|
||||||
k.send(JSON.stringify({ from: connectionId, to: "", type: "call-request", data: connectionId }));
|
k.send(JSON.stringify({ from: connectionId, to: "", type: "call-request", data: connectionId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理广播消息请求
|
* 处理广播消息请求(1对多模式)
|
||||||
* @param ws WebSocket连接实例
|
|
||||||
* @param message 消息数据
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* 处理广播消息请求
|
|
||||||
* @param ws WebSocket连接实例
|
* @param ws WebSocket连接实例
|
||||||
* @param message 消息数据
|
* @param message 消息数据
|
||||||
*/
|
*/
|
||||||
@@ -291,24 +355,22 @@ function onCandidate(ws: WebSocket, message: any): void {
|
|||||||
const targetConnectionId = message.targetConnectionId;
|
const targetConnectionId = message.targetConnectionId;
|
||||||
|
|
||||||
if (targetConnectionId) {
|
if (targetConnectionId) {
|
||||||
// 向指定连接广播
|
// 向指定连接组广播
|
||||||
if (connectionPair.has(targetConnectionId)) {
|
if (connectionGroup.has(targetConnectionId)) {
|
||||||
const pair = connectionPair.get(targetConnectionId);
|
const group = connectionGroup.get(targetConnectionId);
|
||||||
// 向连接对中的两个WebSocket实例发送消息
|
// 向组内所有成员发送消息
|
||||||
if (pair[0]) {
|
group.host.send(JSON.stringify({
|
||||||
pair[0].send(JSON.stringify({
|
|
||||||
type: "broadcast",
|
type: "broadcast",
|
||||||
message: broadcastMessage,
|
message: broadcastMessage,
|
||||||
from: "server"
|
from: "server"
|
||||||
}));
|
}));
|
||||||
}
|
group.participants.forEach(participantWs => {
|
||||||
if (pair[1]) {
|
participantWs.send(JSON.stringify({
|
||||||
pair[1].send(JSON.stringify({
|
|
||||||
type: "broadcast",
|
type: "broadcast",
|
||||||
message: broadcastMessage,
|
message: broadcastMessage,
|
||||||
from: "server"
|
from: "server"
|
||||||
}));
|
}));
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 全局广播:向所有客户端发送消息
|
// 全局广播:向所有客户端发送消息
|
||||||
@@ -353,16 +415,12 @@ function onCandidate(ws: WebSocket, message: any): void {
|
|||||||
*/
|
*/
|
||||||
function onGetAllConnectionIds(): string[] {
|
function onGetAllConnectionIds(): string[] {
|
||||||
// 获取所有connectionId
|
// 获取所有connectionId
|
||||||
const connectionIds = Array.from(connectionPair.keys());
|
const connectionIds = Array.from(connectionGroup.keys());
|
||||||
// 发送连接ID列表给客户端
|
|
||||||
// ws.send(JSON.stringify({
|
|
||||||
// type: "connection-ids",
|
|
||||||
// connectionIds: connectionIds
|
|
||||||
// }));
|
|
||||||
return connectionIds;
|
return connectionIds;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 处理chat-message信令
|
* 处理chat-message信令(1对多模式)
|
||||||
|
* host的消息转发给所有participants,participant的消息转发给host
|
||||||
* @param ws WebSocket连接实例
|
* @param ws WebSocket连接实例
|
||||||
* @param message 消息数据
|
* @param message 消息数据
|
||||||
*/
|
*/
|
||||||
@@ -370,17 +428,20 @@ function onCandidate(ws: WebSocket, message: any): void {
|
|||||||
// 获取连接ID
|
// 获取连接ID
|
||||||
const connectionId = message.connectionId;
|
const connectionId = message.connectionId;
|
||||||
const chatMessage = message.message;
|
const chatMessage = message.message;
|
||||||
if (connectionPair.has(connectionId)) {
|
if (connectionGroup.has(connectionId)) {
|
||||||
const pair = connectionPair.get(connectionId);
|
const group = connectionGroup.get(connectionId);
|
||||||
// 找到另一个WebSocket实例
|
if (group.host === ws) {
|
||||||
const otherSessionWs = pair[0] == ws ? pair[1] : pair[0];
|
// host发送消息,转发给所有participants
|
||||||
if (otherSessionWs) {
|
group.participants.forEach(participantWs => {
|
||||||
// 发送chat-message消息
|
participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "on-message", data: chatMessage }));
|
||||||
otherSessionWs.send(JSON.stringify({ from: connectionId, to: "", type: "on-message", data: chatMessage }));
|
});
|
||||||
|
} else {
|
||||||
|
// participant发送消息,转发给host
|
||||||
|
group.host.send(JSON.stringify({ from: connectionId, to: "", type: "on-message", data: chatMessage }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 导出WebSocket处理器函数
|
* 导出WebSocket处理器函数
|
||||||
*/
|
*/
|
||||||
export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate, onCallConnectionId, onBroadcast, onGetAllConnectionIds, AddHeartbeat, RemoveHeartbeat ,onMessage};
|
export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate, onCallConnectionId, onBroadcast, onGetAllConnectionIds, AddHeartbeat, RemoveHeartbeat, onMessage, isHost, broadcastToGroup, connectionGroup };
|
||||||
|
|||||||
514
WebApp/私有模式和公有模式区别.md
Normal file
514
WebApp/私有模式和公有模式区别.md
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
# WebRTC 信令模式说明:私有模式 vs 公有模式
|
||||||
|
|
||||||
|
## 📊 核心对比
|
||||||
|
|
||||||
|
| 特性 | **私有模式 (private)** | **公有模式 (public)** |
|
||||||
|
|------|----------------------|---------------------|
|
||||||
|
| **连接关系** | 1对多 (Host-Participants) | 多对多 (广播) |
|
||||||
|
| **角色分配** | 有明确的 host 和 participant 角色 | 所有客户端平等,无角色区分 |
|
||||||
|
| **信令路由** | 定向转发 (host ↔ participants) | 全局广播 (除发送者外所有人) |
|
||||||
|
| **连接组管理** | 使用 `connectionGroup` 管理 | 使用 `clients` 全局列表 |
|
||||||
|
| **适用场景** | 视频会议、主控-从控 | 直播、公开房间 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 私有模式 (Private Mode)
|
||||||
|
|
||||||
|
### 工作原理
|
||||||
|
|
||||||
|
```
|
||||||
|
客户端A (Host) ←→ 服务器 ←→ 客户端B (Participant 1)
|
||||||
|
←→ 客户端C (Participant 2)
|
||||||
|
←→ 客户端D (Participant 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键特性
|
||||||
|
|
||||||
|
#### 1. 角色分配机制
|
||||||
|
|
||||||
|
- **第一个**连接到服务器的客户端成为 **Host**
|
||||||
|
- **后续**连接的客户端成为 **Participants**
|
||||||
|
- 通过 `polite` 参数区分角色:
|
||||||
|
- `host`: `polite = false`
|
||||||
|
- `participant`: `polite = true`
|
||||||
|
|
||||||
|
**代码实现** (`src/class/websockethandler.ts` 第 150-176 行):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function onConnect(ws: WebSocket, connectionId: string): void {
|
||||||
|
let polite = true;
|
||||||
|
|
||||||
|
// 处理私有模式
|
||||||
|
if (isPrivate) {
|
||||||
|
if (connectionGroup.has(connectionId)) {
|
||||||
|
const group = connectionGroup.get(connectionId);
|
||||||
|
// 已有host,新连接作为participant加入
|
||||||
|
group.participants.add(ws);
|
||||||
|
console.log(`Participant joined connectionId: ${connectionId}, total participants: ${group.participants.size}`);
|
||||||
|
} else {
|
||||||
|
// 第一个连接成为host
|
||||||
|
connectionGroup.set(connectionId, { host: ws, participants: new Set<WebSocket>() });
|
||||||
|
polite = false;
|
||||||
|
console.log(`Host created connectionId: ${connectionId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送连接成功消息(包含角色信息)
|
||||||
|
const role = polite ? 'participant' : 'host';
|
||||||
|
ws.send(JSON.stringify({ type: "connect", connectionId: connectionId, polite: polite, role: role }));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 信令转发规则
|
||||||
|
|
||||||
|
- **Host 发送** → 转发给**所有 participants**
|
||||||
|
- **Participant 发送** → 只转发给 **host**
|
||||||
|
- **Participants 之间不能直接通信**
|
||||||
|
|
||||||
|
**代码实现** (`src/class/websockethandler.ts` 第 96-108 行):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function broadcastToGroup(connectionId: string, senderWs: WebSocket, message: any): void {
|
||||||
|
const group = connectionGroup.get(connectionId);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
// 如果发送者是host,转发给所有participants
|
||||||
|
if (senderWs === group.host) {
|
||||||
|
group.participants.forEach(participantWs => {
|
||||||
|
participantWs.send(JSON.stringify(message));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 如果发送者是participant,转发给host
|
||||||
|
group.host.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Offer 信令处理
|
||||||
|
|
||||||
|
**代码实现** (`src/class/websockethandler.ts` 第 220-243 行):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function onOffer(ws: WebSocket, message: any): void {
|
||||||
|
const connectionId = message.connectionId as string;
|
||||||
|
const newOffer = new Offer(message.sdp, Date.now(), false);
|
||||||
|
|
||||||
|
// 处理私有模式
|
||||||
|
if (isPrivate) {
|
||||||
|
if (connectionGroup.has(connectionId)) {
|
||||||
|
const group = connectionGroup.get(connectionId);
|
||||||
|
if (group.host === ws) {
|
||||||
|
// host发送offer,转发给所有participants
|
||||||
|
newOffer.polite = true;
|
||||||
|
group.participants.forEach(participantWs => {
|
||||||
|
participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer }));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// participant发送offer,转发给host
|
||||||
|
newOffer.polite = true;
|
||||||
|
group.host.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ... 公有模式处理
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 断开连接处理
|
||||||
|
|
||||||
|
- **Host 断开** → 通知所有 participants,删除整个连接组
|
||||||
|
- **Participant 断开** → 只通知 host,从 participants 中移除
|
||||||
|
|
||||||
|
**代码实现** (`src/class/websockethandler.ts` 第 114-142 行):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function remove(ws: WebSocket): void {
|
||||||
|
const connectionIds = clients.get(ws);
|
||||||
|
if (!connectionIds) return;
|
||||||
|
|
||||||
|
connectionIds.forEach(connectionId => {
|
||||||
|
const group = connectionGroup.get(connectionId);
|
||||||
|
if (group) {
|
||||||
|
if (group.host === ws) {
|
||||||
|
// host断开连接,通知所有participants
|
||||||
|
group.participants.forEach(participantWs => {
|
||||||
|
participantWs.send(JSON.stringify({ type: "disconnect", connectionId: connectionId }));
|
||||||
|
});
|
||||||
|
// 删除整个连接组
|
||||||
|
connectionGroup.delete(connectionId);
|
||||||
|
} else {
|
||||||
|
// participant断开连接,从participants中移除并通知host
|
||||||
|
group.participants.delete(ws);
|
||||||
|
group.host.send(JSON.stringify({ type: "disconnect", connectionId: connectionId }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Remove connectionId: ${connectionId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
clients.delete(ws);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据结构
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ConnectionGroup {
|
||||||
|
host: WebSocket; // 主机(第一个连接)
|
||||||
|
participants: Set<WebSocket>; // 参与者集合(后续连接)
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionGroup: Map<string, ConnectionGroup> = new Map<string, ConnectionGroup>();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 应用场景
|
||||||
|
|
||||||
|
- ✅ **视频会议** (1个主播 + 多个观众)
|
||||||
|
- ✅ **远程桌面控制** (1个主控 + 多个观察者)
|
||||||
|
- ✅ **直播互动** (主播与观众连麦)
|
||||||
|
- ✅ **教学系统** (教师 + 多个学生)
|
||||||
|
- ✅ **主从控制** (Unity 应用为主机,多个 Web 客户端为参与者)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 公有模式 (Public Mode)
|
||||||
|
|
||||||
|
### 工作原理
|
||||||
|
|
||||||
|
```
|
||||||
|
客户端A ←→ 服务器 ←→ 客户端B
|
||||||
|
↕ (广播) ↕
|
||||||
|
客户端C ←────────────→ 客户端D
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键特性
|
||||||
|
|
||||||
|
#### 1. 无角色区分
|
||||||
|
|
||||||
|
- 所有客户端地位平等
|
||||||
|
- 没有 host/participant 的概念
|
||||||
|
- `polite` 始终为 `true`
|
||||||
|
|
||||||
|
#### 2. 全局广播机制
|
||||||
|
|
||||||
|
- 任何客户端发送的消息 → **广播给所有其他客户端**
|
||||||
|
- 支持 **多对多** 通信
|
||||||
|
- 每个客户端既是发送者也是接收者
|
||||||
|
|
||||||
|
**代码实现** (`src/class/websockethandler.ts` 第 245-256 行):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function onOffer(ws: WebSocket, message: any): void {
|
||||||
|
const connectionId = message.connectionId as string;
|
||||||
|
const newOffer = new Offer(message.sdp, Date.now(), false);
|
||||||
|
|
||||||
|
// ... 私有模式处理
|
||||||
|
|
||||||
|
// 公共模式:创建新的连接组(如果不存在)
|
||||||
|
if (!connectionGroup.has(connectionId)) {
|
||||||
|
connectionGroup.set(connectionId, { host: ws, participants: new Set<WebSocket>() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 向所有其他客户端广播offer
|
||||||
|
clients.forEach((_v, k) => {
|
||||||
|
if (k == ws) {
|
||||||
|
return; // 跳过发送者
|
||||||
|
}
|
||||||
|
k.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 对等通信
|
||||||
|
|
||||||
|
**消息流转示例**:
|
||||||
|
```
|
||||||
|
A 发送消息 → B、C、D 都收到
|
||||||
|
B 发送消息 → A、C、D 都收到
|
||||||
|
C 发送消息 → A、B、D 都收到
|
||||||
|
D 发送消息 → A、B、C 都收到
|
||||||
|
```
|
||||||
|
|
||||||
|
### 应用场景
|
||||||
|
|
||||||
|
- ✅ **多人聊天室** (所有用户平等)
|
||||||
|
- ✅ **公开游戏房间** (多玩家对战)
|
||||||
|
- ✅ **协作编辑** (多人实时编辑)
|
||||||
|
- ✅ **广播直播** (单向推流,多人观看)
|
||||||
|
- ✅ **P2P 网状网络** (Full Mesh 拓扑)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 启动方式
|
||||||
|
|
||||||
|
### 私有模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 npm 脚本
|
||||||
|
npm start -- -m private
|
||||||
|
|
||||||
|
# 或直接运行
|
||||||
|
node ./build/index.js -s -p 8080 -m private -k ./server.key -c ./server.cert
|
||||||
|
|
||||||
|
# 开发模式
|
||||||
|
npm run dev -- -m private
|
||||||
|
```
|
||||||
|
|
||||||
|
### 公有模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 npm 脚本(默认模式)
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# 或明确指定
|
||||||
|
npm start -- -m public
|
||||||
|
|
||||||
|
# 或直接运行
|
||||||
|
node ./build/index.js -s -p 8080 -m public -k ./server.key -c ./server.cert
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `-m` | 通信模式 | `-m private` 或 `-m public` |
|
||||||
|
| `-p` | 端口号 | `-p 8080` |
|
||||||
|
| `-s` | 启用 HTTPS | `-s` |
|
||||||
|
| `-k` | 密钥文件 | `-k ./server.key` |
|
||||||
|
| `-c` | 证书文件 | `-c ./server.cert` |
|
||||||
|
| `-t` | 信令类型 | `-t websocket` 或 `-t http` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 实际例子
|
||||||
|
|
||||||
|
### 私有模式示例
|
||||||
|
|
||||||
|
假设有 3 个客户端连接到同一个 `connectionId: "room1"`:
|
||||||
|
|
||||||
|
**连接顺序**:
|
||||||
|
1. **客户端A** 先连接 → 成为 **Host** (`polite: false`)
|
||||||
|
2. **客户端B** 连接 → 成为 **Participant 1** (`polite: true`)
|
||||||
|
3. **客户端C** 连接 → 成为 **Participant 2** (`polite: true`)
|
||||||
|
|
||||||
|
**消息流转**:
|
||||||
|
```
|
||||||
|
A (Host) 发送消息
|
||||||
|
→ B 收到
|
||||||
|
→ C 收到
|
||||||
|
|
||||||
|
B (Participant) 发送消息
|
||||||
|
→ A 收到
|
||||||
|
→ C 收不到 ❌
|
||||||
|
|
||||||
|
C (Participant) 发送消息
|
||||||
|
→ A 收到
|
||||||
|
→ B 收不到 ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
**信令流程图**:
|
||||||
|
```
|
||||||
|
B --[offer]--> 服务器 --[offer]--> A (Host)
|
||||||
|
A --[answer]--> 服务器 --[answer]--> B
|
||||||
|
A --[offer]--> 服务器 --[offer]--> C
|
||||||
|
C --[answer]--> 服务器 --[answer]--> A
|
||||||
|
```
|
||||||
|
|
||||||
|
### 公有模式示例
|
||||||
|
|
||||||
|
假设有 3 个客户端连接:
|
||||||
|
|
||||||
|
**连接顺序**:
|
||||||
|
1. **客户端A** 连接 (`polite: true`)
|
||||||
|
2. **客户端B** 连接 (`polite: true`)
|
||||||
|
3. **客户端C** 连接 (`polite: true`)
|
||||||
|
|
||||||
|
**消息流转**:
|
||||||
|
```
|
||||||
|
A 发送消息
|
||||||
|
→ B 收到 ✅
|
||||||
|
→ C 收到 ✅
|
||||||
|
|
||||||
|
B 发送消息
|
||||||
|
→ A 收到 ✅
|
||||||
|
→ C 收到 ✅
|
||||||
|
|
||||||
|
C 发送消息
|
||||||
|
→ A 收到 ✅
|
||||||
|
→ B 收到 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
**信令流程图**:
|
||||||
|
```
|
||||||
|
A --[offer]--> 服务器 --[offer]--> B
|
||||||
|
A --[offer]--> 服务器 --[offer]--> C
|
||||||
|
B --[offer]--> 服务器 --[offer]--> A
|
||||||
|
B --[offer]--> 服务器 --[offer]--> C
|
||||||
|
C --[offer]--> 服务器 --[offer]--> A
|
||||||
|
C --[offer]--> 服务器 --[offer]--> B
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 代码中的模式判断
|
||||||
|
|
||||||
|
### 初始化模式
|
||||||
|
|
||||||
|
**`src/class/websockethandler.ts` 第 62-65 行**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function reset(mode: string): void {
|
||||||
|
// 设置是否为私有模式
|
||||||
|
isPrivate = mode == "private";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式判断逻辑
|
||||||
|
|
||||||
|
在各个信令处理函数中,通过 `isPrivate` 变量判断:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Offer 处理
|
||||||
|
if (isPrivate) {
|
||||||
|
// 私有模式逻辑:定向转发
|
||||||
|
} else {
|
||||||
|
// 公有模式逻辑:全局广播
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate 处理
|
||||||
|
if (isPrivate) {
|
||||||
|
// 私有模式逻辑
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message 处理
|
||||||
|
if (connectionGroup.has(connectionId)) {
|
||||||
|
const group = connectionGroup.get(connectionId);
|
||||||
|
if (group.host === ws) {
|
||||||
|
// host 发送 → 转发给 participants
|
||||||
|
} else {
|
||||||
|
// participant 发送 → 转发给 host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 模式选择建议
|
||||||
|
|
||||||
|
### 选择私有模式的场景
|
||||||
|
|
||||||
|
- ✅ 需要明确的主从关系
|
||||||
|
- ✅ 中心节点需要控制所有通信
|
||||||
|
- ✅ 参与者之间不需要直接通信
|
||||||
|
- ✅ 需要管理连接层级结构
|
||||||
|
- ✅ 资源优化(减少不必要的 P2P 连接)
|
||||||
|
|
||||||
|
### 选择公有模式的场景
|
||||||
|
|
||||||
|
- ✅ 所有参与者地位平等
|
||||||
|
- ✅ 需要多对多通信
|
||||||
|
- ✅ Full Mesh P2P 拓扑
|
||||||
|
- ✅ 每个客户端都需要相互连接
|
||||||
|
- ✅ 去中心化的应用场景
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 性能对比
|
||||||
|
|
||||||
|
| 指标 | 私有模式 | 公有模式 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| **连接数** | O(n) - 线性增长 | O(n²) - 平方增长 |
|
||||||
|
| **服务器负载** | 中等(需要路由转发) | 较低(主要是广播) |
|
||||||
|
| **客户端负载** | 较低(只与 host 建立连接) | 较高(需要与所有客户端建立连接) |
|
||||||
|
| **网络带宽** | 节省(定向传输) | 较高(广播传输) |
|
||||||
|
| **扩展性** | 好(适合大量 participants) | 受限(连接数随用户数平方增长) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 测试方式
|
||||||
|
|
||||||
|
### 单元测试
|
||||||
|
|
||||||
|
项目中已包含完整的单元测试:
|
||||||
|
|
||||||
|
**后端测试** (`test/websockethandler.test.ts`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 公有模式测试
|
||||||
|
describe('websocket signaling test in public mode', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
wsHandler.reset("public");
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 私有模式测试
|
||||||
|
describe('websocket signaling test in private mode', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
wsHandler.reset("private");
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端测试** (`client/test/signaling.test.js`):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 公有模式测试
|
||||||
|
describe.each([
|
||||||
|
{ mode: "mock" },
|
||||||
|
{ mode: "http" },
|
||||||
|
{ mode: "websocket" },
|
||||||
|
])('signaling test in public mode', ({ mode }) => {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
// 私有模式测试
|
||||||
|
describe.each([
|
||||||
|
{ mode: "mock" },
|
||||||
|
{ mode: "http" },
|
||||||
|
{ mode: "websocket" },
|
||||||
|
])('signaling test in private mode', ({ mode }) => {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行后端测试
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# 运行前端测试
|
||||||
|
cd client
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文件
|
||||||
|
|
||||||
|
- **WebSocket 处理器**: `src/class/websockethandler.ts`
|
||||||
|
- **HTTP 处理器**: `src/class/httphandler.ts`
|
||||||
|
- **WebSocket 服务**: `src/websocket.ts`
|
||||||
|
- **前端信令**: `client/src/signaling.js`
|
||||||
|
- **主入口**: `src/index.ts`
|
||||||
|
- **后端测试**: `test/websockethandler.test.ts`
|
||||||
|
- **前端测试**: `client/test/signaling.test.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 总结
|
||||||
|
|
||||||
|
| 模式 | 核心特点 | 最佳用途 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| **私有模式** | 1对多、主从架构、定向转发 | 视频会议、远程控制、教学系统 |
|
||||||
|
| **公有模式** | 多对多、对等网络、全局广播 | 聊天室、多人游戏、协作编辑 |
|
||||||
|
|
||||||
|
选择合适的模式取决于你的应用架构需求和通信拓扑结构。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档生成时间**: 2026-04-22
|
||||||
|
**项目版本**: 3.1.0
|
||||||
Reference in New Issue
Block a user