初始化
This commit is contained in:
8
src/class/answer.ts
Normal file
8
src/class/answer.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default class Answer {
|
||||
sdp: string;
|
||||
datetime: number;
|
||||
constructor(sdp: string, datetime: number) {
|
||||
this.sdp = sdp;
|
||||
this.datetime = datetime;
|
||||
}
|
||||
}
|
||||
12
src/class/candidate.ts
Normal file
12
src/class/candidate.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default class Candidate {
|
||||
candidate: string;
|
||||
sdpMLineIndex: number;
|
||||
sdpMid: string;
|
||||
datetime: number;
|
||||
constructor(candidate: string, sdpMLineIndex: number, sdpMid: string, datetime: number) {
|
||||
this.candidate = candidate;
|
||||
this.sdpMLineIndex = sdpMLineIndex;
|
||||
this.sdpMid = sdpMid;
|
||||
this.datetime = datetime;
|
||||
}
|
||||
}
|
||||
1128
src/class/httphandler.ts
Normal file
1128
src/class/httphandler.ts
Normal file
File diff suppressed because it is too large
Load Diff
10
src/class/offer.ts
Normal file
10
src/class/offer.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default class Offer {
|
||||
sdp: string;
|
||||
datetime: number;
|
||||
polite: boolean;
|
||||
constructor(sdp: string, datetime: number, polite: boolean) {
|
||||
this.sdp = sdp;
|
||||
this.datetime = datetime;
|
||||
this.polite = polite;
|
||||
}
|
||||
}
|
||||
9
src/class/options.ts
Normal file
9
src/class/options.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default interface Options {
|
||||
secure?: boolean;
|
||||
port?: number;
|
||||
keyfile?: string;
|
||||
certfile?: string;
|
||||
type?: string;
|
||||
mode?: string;
|
||||
logging?: string;
|
||||
}
|
||||
475
src/class/websockethandler.ts
Normal file
475
src/class/websockethandler.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* WebSocket处理器
|
||||
* 负责管理WebSocket连接和信令消息处理
|
||||
*/
|
||||
import Offer from './offer';
|
||||
import Answer from './answer';
|
||||
import Candidate from './candidate';
|
||||
|
||||
/**
|
||||
* 是否为私有模式
|
||||
*/
|
||||
let isPrivate: boolean;
|
||||
|
||||
/**
|
||||
* 客户端连接映射
|
||||
* 键: WebSocket实例
|
||||
* 值: 该WebSocket的连接ID集合
|
||||
*/
|
||||
const clients: Map<WebSocket, Set<string>> = new Map<WebSocket, Set<string>>();
|
||||
|
||||
/**
|
||||
* 连接组结构
|
||||
* host: 主机WebSocket实例(第一个连接的客户端)
|
||||
* participants: 参与者WebSocket集合(后续连接的客户端)
|
||||
*/
|
||||
interface ConnectionGroup {
|
||||
host: WebSocket;
|
||||
participants: Set<WebSocket>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接组映射
|
||||
* 键: connectionId
|
||||
* 值: ConnectionGroup(1个host + 多个participants)
|
||||
*/
|
||||
const connectionGroup: Map<string, ConnectionGroup> = new Map<string, ConnectionGroup>();
|
||||
|
||||
/**
|
||||
* 获取或创建WebSocket会话的连接ID集合
|
||||
* @param session WebSocket会话实例
|
||||
* @returns 连接ID的Set集合
|
||||
*/
|
||||
function getOrCreateConnectionIds(session: WebSocket): Set<string> {
|
||||
let connectionIds = null;
|
||||
// 检查客户端是否已存在
|
||||
if (!clients.has(session)) {
|
||||
// 如果不存在,创建新的连接ID集合
|
||||
connectionIds = new Set<string>();
|
||||
// 将新的连接ID集合与客户端关联
|
||||
clients.set(session, connectionIds);
|
||||
}
|
||||
// 获取客户端的连接ID集合
|
||||
connectionIds = clients.get(session);
|
||||
// 返回连接ID集合
|
||||
return connectionIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置处理器状态
|
||||
* @param mode 通信模式(public或private)
|
||||
*/
|
||||
function reset(mode: string): void {
|
||||
// 设置是否为私有模式
|
||||
isPrivate = mode == "private";
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加新的WebSocket连接
|
||||
* @param ws WebSocket连接实例
|
||||
*/
|
||||
function add(ws: WebSocket): void {
|
||||
// 为新连接创建空的连接ID集合
|
||||
const id = new Set<string>();
|
||||
clients.set(ws, id);
|
||||
// 记录添加WebSocket连接的日志
|
||||
console.log(`Add WebSocket: ${ws.url}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断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连接
|
||||
* @param ws WebSocket连接实例
|
||||
*/
|
||||
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) {
|
||||
group.participants.forEach(participantWs => {
|
||||
participantWs.send(JSON.stringify({ type: "disconnect", connectionId: connectionId, reason: "host-left" }));
|
||||
});
|
||||
connectionGroup.delete(connectionId);
|
||||
} else {
|
||||
group.participants.delete(ws);
|
||||
// 包含participantId,让host能识别是哪个participant离开
|
||||
group.host.send(JSON.stringify({ type: "participant-left", connectionId: connectionId, participantId: (ws as any).participantId }));
|
||||
}
|
||||
}
|
||||
console.log(`Remove connectionId: ${connectionId}`);
|
||||
});
|
||||
|
||||
clients.delete(ws);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理连接请求(1对多模式)
|
||||
* 第一个连接的客户端成为host,后续连接的客户端成为participants
|
||||
* @param ws WebSocket连接实例
|
||||
* @param connectionId 连接ID
|
||||
*/
|
||||
function onConnect(ws: WebSocket, connectionId: string): void {
|
||||
let polite = true;
|
||||
// 为每个WebSocket生成唯一的participantId
|
||||
const participantId = (ws as any).participantId = (ws as any).participantId || `p_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
if (isPrivate) {
|
||||
if (connectionGroup.has(connectionId)) {
|
||||
const group = connectionGroup.get(connectionId);
|
||||
group.participants.add(ws);
|
||||
console.log(`Participant ${participantId} joined connectionId: ${connectionId}, total participants: ${group.participants.size}`);
|
||||
// 通知host有新participant加入
|
||||
group.host.send(JSON.stringify({ type: "participant-joined", connectionId: connectionId, participantId: participantId }));
|
||||
} else {
|
||||
connectionGroup.set(connectionId, { host: ws, participants: new Set<WebSocket>() });
|
||||
polite = false;
|
||||
console.log(`Host created connectionId: ${connectionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const connectionIds = getOrCreateConnectionIds(ws);
|
||||
connectionIds.add(connectionId);
|
||||
const role = polite ? 'participant' : 'host';
|
||||
ws.send(JSON.stringify({ type: "connect", connectionId: connectionId, polite: polite, role: role, participantId: participantId }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理断开连接请求(1对多模式)
|
||||
* @param ws WebSocket连接实例
|
||||
* @param connectionId 连接ID
|
||||
*/
|
||||
function onDisconnect(ws: WebSocket, connectionId: string): void {
|
||||
// 获取连接的连接ID集合
|
||||
const connectionIds = clients.get(ws);
|
||||
if (connectionIds) {
|
||||
// 从集合中删除连接ID
|
||||
connectionIds.delete(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, reason: "host-left" }));
|
||||
});
|
||||
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, participantId: (ws as any).participantId }));
|
||||
console.log(`Participant left connectionId: ${connectionId}, remaining participants: ${group.participants.size}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 向当前连接发送断开连接消息
|
||||
ws.send(JSON.stringify({ type: "disconnect", connectionId: connectionId }));
|
||||
//RemoveHeartbeat(ws);
|
||||
// 记录断开连接的日志
|
||||
console.log(`Disconnect connectionId: ${connectionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理offer信令(1对多模式)
|
||||
* host的offer转发给所有participants,participant的offer转发给host
|
||||
* @param ws WebSocket连接实例
|
||||
* @param message 消息数据
|
||||
*/
|
||||
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);
|
||||
const senderParticipantId = (ws as any).participantId;
|
||||
const targetParticipantId = message.participantId;
|
||||
if (group.host === ws) {
|
||||
// host发送offer给特定participant(多peer模式下按participantId路由)
|
||||
newOffer.polite = true;
|
||||
if (targetParticipantId) {
|
||||
// 路由到指定participant
|
||||
group.participants.forEach(participantWs => {
|
||||
if ((participantWs as any).participantId === targetParticipantId) {
|
||||
participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer, participantId: targetParticipantId }));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 兼容:无目标时广播给所有participants
|
||||
group.participants.forEach(participantWs => {
|
||||
const pid = (participantWs as any).participantId;
|
||||
participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer, participantId: pid }));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// participant发送offer给host,携带该participant的participantId
|
||||
newOffer.polite = true;
|
||||
group.host.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer, participantId: senderParticipantId }));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 公共模式:创建新的连接组(如果不存在)
|
||||
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 }));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理answer信令(1对多模式)
|
||||
* participant的answer转发给host
|
||||
* @param ws WebSocket连接实例
|
||||
* @param message 消息数据
|
||||
*/
|
||||
function onAnswer(ws: WebSocket, message: any): void {
|
||||
const connectionId = message.connectionId as string;
|
||||
const connectionIds = getOrCreateConnectionIds(ws);
|
||||
connectionIds.add(connectionId);
|
||||
const newAnswer = new Answer(message.sdp, Date.now());
|
||||
|
||||
if (!connectionGroup.has(connectionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = connectionGroup.get(connectionId);
|
||||
const senderParticipantId = (ws as any).participantId;
|
||||
// 从answer消息中获取目标participantId(host回复时指定)
|
||||
const targetParticipantId = message.participantId;
|
||||
|
||||
if (group.host === ws) {
|
||||
// host发送answer给特定participant
|
||||
if (targetParticipantId) {
|
||||
group.participants.forEach(participantWs => {
|
||||
if ((participantWs as any).participantId === targetParticipantId) {
|
||||
participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "answer", data: newAnswer, participantId: targetParticipantId }));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 兼容:没有targetParticipantId时广播给所有participants
|
||||
group.participants.forEach(participantWs => {
|
||||
participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "answer", data: newAnswer }));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// participant发送answer给host,携带自己的participantId
|
||||
group.host.send(JSON.stringify({ from: connectionId, to: "", type: "answer", data: newAnswer, participantId: senderParticipantId }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理candidate信令(1对多模式)
|
||||
* host的candidate转发给所有participants,participant的candidate转发给host
|
||||
* @param ws WebSocket连接实例
|
||||
* @param message 消息数据
|
||||
*/
|
||||
function onCandidate(ws: WebSocket, message: any): void {
|
||||
const connectionId = message.connectionId;
|
||||
const candidate = new Candidate(message.candidate, message.sdpMLineIndex, message.sdpMid, Date.now());
|
||||
const senderParticipantId = (ws as any).participantId;
|
||||
const targetParticipantId = message.participantId;
|
||||
|
||||
if (isPrivate) {
|
||||
if (connectionGroup.has(connectionId)) {
|
||||
const group = connectionGroup.get(connectionId);
|
||||
if (group.host === ws) {
|
||||
// host发送candidate给特定participant
|
||||
if (targetParticipantId) {
|
||||
group.participants.forEach(participantWs => {
|
||||
if ((participantWs as any).participantId === targetParticipantId) {
|
||||
participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "candidate", data: candidate, participantId: targetParticipantId }));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
group.participants.forEach(participantWs => {
|
||||
participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "candidate", data: candidate }));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// participant发送candidate给host,携带自己的participantId
|
||||
group.host.send(JSON.stringify({ from: connectionId, to: "", type: "candidate", data: candidate, participantId: senderParticipantId }));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onCallConnectionId(ws: WebSocket, message: any): void {
|
||||
// 获取连接ID
|
||||
const connectionId = message.connectionId;
|
||||
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) => {
|
||||
if (k === ws) {
|
||||
return;
|
||||
}
|
||||
if (_v == clientId) {
|
||||
k.send(JSON.stringify({ from: connectionId, to: "", type: "call-request", data: connectionId }));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 处理广播消息请求(1对多模式)
|
||||
* @param ws WebSocket连接实例
|
||||
* @param message 消息数据
|
||||
*/
|
||||
function onBroadcast(ws: WebSocket, message: any): void {
|
||||
const broadcastMessage = message.message;
|
||||
const targetConnectionId = message.targetConnectionId;
|
||||
|
||||
if (targetConnectionId) {
|
||||
// 向指定连接组广播
|
||||
if (connectionGroup.has(targetConnectionId)) {
|
||||
const group = connectionGroup.get(targetConnectionId);
|
||||
// 向组内所有成员发送消息
|
||||
group.host.send(JSON.stringify({
|
||||
type: "broadcast",
|
||||
message: broadcastMessage,
|
||||
from: "server"
|
||||
}));
|
||||
group.participants.forEach(participantWs => {
|
||||
participantWs.send(JSON.stringify({
|
||||
type: "broadcast",
|
||||
message: broadcastMessage,
|
||||
from: "server"
|
||||
}));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 全局广播:向所有客户端发送消息
|
||||
clients.forEach((_v, k) => {
|
||||
k.send(JSON.stringify({
|
||||
type: "broadcast",
|
||||
message: broadcastMessage,
|
||||
from: "server"
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function AddHeartbeat(ws: WebSocket, connectionId: string) {
|
||||
// 初始化心跳检测
|
||||
(ws as any).lastActivity = Date.now();
|
||||
|
||||
// 设置心跳检测定时器,每30秒发送一次ping
|
||||
(ws as any).heartbeatTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
// 检查上次活动时间,如果超过60秒没有活动,关闭连接
|
||||
if (now - (ws as any).lastActivity > 10000) {
|
||||
console.log('WebSocket connection timeout, closing...');
|
||||
clearInterval((ws as any).heartbeatTimer);
|
||||
//ws.close();
|
||||
onDisconnect(ws, connectionId);
|
||||
} else {
|
||||
// 发送ping消息
|
||||
ws.send(JSON.stringify({ from: connectionId, to: "", type: "on-message", data: { type: "ping"} }));
|
||||
console.log('WebSocket connection heartbeat, lastActivity: ', (ws as any).lastActivity);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function RemoveHeartbeat(ws: WebSocket) {
|
||||
// 清除心跳检测定时器
|
||||
if ((ws as any).heartbeatTimer) {
|
||||
clearInterval((ws as any).heartbeatTimer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理获取所有连接ID的请求
|
||||
* @param ws WebSocket连接实例
|
||||
*/
|
||||
function onGetAllConnectionIds(): string[] {
|
||||
// 获取所有connectionId
|
||||
const connectionIds = Array.from(connectionGroup.keys());
|
||||
return connectionIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理chat-message信令(1对多模式)
|
||||
* host的消息转发给所有participants,participant的消息转发给host
|
||||
* @param ws WebSocket连接实例
|
||||
* @param message 消息数据
|
||||
*/
|
||||
function onMessage(ws: WebSocket, message: any): void {
|
||||
// 获取连接ID
|
||||
const connectionId = message.connectionId;
|
||||
const chatMessage = message.message;
|
||||
const senderParticipantId = (ws as any).participantId;
|
||||
if (connectionGroup.has(connectionId)) {
|
||||
const group = connectionGroup.get(connectionId);
|
||||
if (group.host === ws) {
|
||||
// host发送消息,转发给所有participants
|
||||
group.participants.forEach(participantWs => {
|
||||
participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "on-message", data: chatMessage }));
|
||||
});
|
||||
} else {
|
||||
// participant发送消息,转发给host(附带participantId)和其他participants
|
||||
group.host.send(JSON.stringify({ from: connectionId, to: "", type: "on-message", data: chatMessage, participantId: senderParticipantId }));
|
||||
// 同时转发给其他participants(排除发送者自身)
|
||||
group.participants.forEach(participantWs => {
|
||||
if (participantWs !== ws) {
|
||||
participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "on-message", data: chatMessage, participantId: senderParticipantId }));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出WebSocket处理器函数
|
||||
*/
|
||||
export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate, onCallConnectionId, onBroadcast, onGetAllConnectionIds, AddHeartbeat, RemoveHeartbeat, onMessage, isHost, broadcastToGroup, connectionGroup };
|
||||
107
src/index.ts
Normal file
107
src/index.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Command } from 'commander';
|
||||
import * as express from 'express';
|
||||
import * as https from 'https';
|
||||
import { Server } from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { createServer } from './server';
|
||||
import { AddressInfo } from 'net';
|
||||
import WSSignaling from './websocket';
|
||||
import Options from './class/options';
|
||||
|
||||
export class RenderStreaming {
|
||||
public static run(argv: string[]): RenderStreaming {
|
||||
const program = new Command();
|
||||
const readOptions = (): Options => {
|
||||
// 确保argv是数组
|
||||
const args = Array.isArray(argv) ? argv : process.argv;
|
||||
|
||||
program
|
||||
.usage('[options] <apps...>')
|
||||
.option('-p, --port <n>', 'Port to start the server on.', process.env.PORT || `80`)
|
||||
.option('-s, --secure', 'Enable HTTPS (you need server.key and server.cert).', process.env.SECURE || true)
|
||||
.option('-k, --keyfile <path>', 'https key file.', process.env.KEYFILE || 'server.key')
|
||||
.option('-c, --certfile <path>', 'https cert file.', process.env.CERTFILE || 'server.cert')
|
||||
.option('-t, --type <type>', 'Type of signaling protocol, Choose websocket or http.', process.env.TYPE || 'websocket')
|
||||
.option('-m, --mode <type>', 'Choose Communication mode public or private.', process.env.MODE || 'public')
|
||||
.option('-l, --logging <type>', 'Choose http logging type combined, dev, short, tiny or none.', process.env.LOGGING || 'dev')
|
||||
.parse(args);
|
||||
|
||||
const option = program.opts();
|
||||
return {
|
||||
port: option.port,
|
||||
secure: option.secure == undefined ? false : option.secure,
|
||||
keyfile: option.keyfile,
|
||||
certfile: option.certfile,
|
||||
type: option.type == undefined ? 'websocket' : option.type,
|
||||
mode: option.mode,
|
||||
logging: option.logging,
|
||||
};
|
||||
};
|
||||
const options = readOptions();
|
||||
return new RenderStreaming(options);
|
||||
}
|
||||
|
||||
public app: express.Application;
|
||||
|
||||
public server?: Server;
|
||||
|
||||
public options: Options;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.options = options;
|
||||
this.app = createServer(this.options);
|
||||
if (this.options.secure) {
|
||||
this.server = https.createServer({
|
||||
key: fs.readFileSync(options.keyfile),
|
||||
cert: fs.readFileSync(options.certfile),
|
||||
}, this.app).listen(this.options.port, () => {
|
||||
const { port } = this.server.address() as AddressInfo;
|
||||
const addresses = this.getIPAddress();
|
||||
for (const address of addresses) {
|
||||
console.log(`https://${address}:${port}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.server = this.app.listen(this.options.port, () => {
|
||||
const { port } = this.server.address() as AddressInfo;
|
||||
const addresses = this.getIPAddress();
|
||||
for (const address of addresses) {
|
||||
console.log(`http://${address}:${port}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.options.type == 'http') {
|
||||
console.log(`Use http polling for signaling server.`);
|
||||
}
|
||||
else if(this.options.type != 'websocket') {
|
||||
console.log(`signaling type should be set "websocket" or "http". ${this.options.type} is not supported.`);
|
||||
console.log(`Changing signaling type to websocket.`);
|
||||
this.options.type = 'websocket';
|
||||
}
|
||||
if (this.options.type == 'websocket') {
|
||||
console.log(`Use websocket for signaling server ws://${this.getIPAddress()[0]}`);
|
||||
|
||||
//Start Websocket Signaling server
|
||||
new WSSignaling(this.server, this.options.mode);
|
||||
}
|
||||
|
||||
console.log(`start as ${this.options.mode} mode`);
|
||||
}
|
||||
|
||||
getIPAddress(): string[] {
|
||||
const interfaces = os.networkInterfaces();
|
||||
const addresses: string[] = [];
|
||||
for (const k in interfaces) {
|
||||
for (const k2 in interfaces[k]) {
|
||||
const address = interfaces[k][k2];
|
||||
if (address.family === 'IPv4') {
|
||||
addresses.push(address.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
}
|
||||
|
||||
RenderStreaming.run(process.argv);
|
||||
27
src/log.ts
Normal file
27
src/log.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
const isDebug = true;
|
||||
|
||||
export enum LogLevel {
|
||||
info,
|
||||
log,
|
||||
warn,
|
||||
error,
|
||||
}
|
||||
|
||||
export function log(level: LogLevel, ...args: any[]): void {
|
||||
if (isDebug) {
|
||||
switch (level) {
|
||||
case LogLevel.log:
|
||||
console.log(...args);
|
||||
break;
|
||||
case LogLevel.info:
|
||||
console.info(...args);
|
||||
break;
|
||||
case LogLevel.warn:
|
||||
console.warn(...args);
|
||||
break;
|
||||
case LogLevel.error:
|
||||
console.error(...args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/server.ts
Normal file
89
src/server.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as express from 'express';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as morgan from 'morgan';
|
||||
import signaling from './signaling';
|
||||
import { log, LogLevel } from './log';
|
||||
import Options from './class/options';
|
||||
import { reset as resetHandler }from './class/httphandler';
|
||||
import { initSwagger } from './swagger';
|
||||
|
||||
const cors = require('cors');
|
||||
const multer = require('multer');
|
||||
|
||||
export const createServer = (config: Options): express.Express => {
|
||||
const app: express.Express = express();
|
||||
resetHandler(config.mode);
|
||||
// logging http access
|
||||
if (config.logging != "none") {
|
||||
app.use(morgan(config.logging));
|
||||
}
|
||||
// const signal = require('./signaling');
|
||||
app.use(cors({origin: '*'}));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.get('/config', (req, res) => res.json({ useWebSocket: config.type == 'websocket', startupMode: config.mode, logging: config.logging }));
|
||||
app.use('/signaling', signaling);
|
||||
app.use(express.static(path.join(__dirname, '../client/public')));
|
||||
app.use('/module', express.static(path.join(__dirname, '../client/src')));
|
||||
app.get('/', (req, res) => {
|
||||
const indexPagePath: string = path.join(__dirname, '../client/public/index.html');
|
||||
fs.access(indexPagePath, (err) => {
|
||||
if (err) {
|
||||
log(LogLevel.warn, `Can't find file ' ${indexPagePath}`);
|
||||
res.status(404).send(`Can't find file ${indexPagePath}`);
|
||||
} else {
|
||||
res.sendFile(indexPagePath);
|
||||
}
|
||||
});
|
||||
});
|
||||
// 初始化Swagger
|
||||
initSwagger(app, config);
|
||||
|
||||
// 配置multer存储
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req: any, file: any, cb: (error: Error | null, destination: string) => void) {
|
||||
// 确保上传目录存在
|
||||
const uploadDir = path.join(__dirname, '../client/public/uploads/avatars');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (req: any, file: any, cb: (error: Error | null, filename: string) => void) {
|
||||
// 临时使用原始文件名,稍后在API处理中重命名
|
||||
cb(null, file.originalname);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({ storage: storage });
|
||||
|
||||
// 头像上传API
|
||||
app.post('/api/upload/avatar', upload.single('avatar'), (req: any, res: express.Response) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, message: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const userId = req.body.userId || 'unknown';
|
||||
const ext = path.extname(req.file.originalname);
|
||||
const oldPath = req.file.path;
|
||||
const newFilename = `${userId}${ext}`;
|
||||
const newPath = path.join(path.dirname(oldPath), newFilename);
|
||||
|
||||
// 重命名文件
|
||||
fs.rename(oldPath, newPath, (err) => {
|
||||
if (err) {
|
||||
console.error('Error renaming file:', err);
|
||||
return res.status(500).json({ success: false, message: '文件重命名失败' });
|
||||
}
|
||||
|
||||
const avatarUrl = `/uploads/avatars/${newFilename}`;
|
||||
res.json({ success: true, avatarUrl: avatarUrl });
|
||||
});
|
||||
});
|
||||
|
||||
// 确保uploads目录可访问
|
||||
app.use('/uploads', express.static(path.join(__dirname, '../client/public/uploads')));
|
||||
|
||||
return app;
|
||||
};
|
||||
24
src/signaling.ts
Normal file
24
src/signaling.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as express from 'express';
|
||||
import * as handler from'./class/httphandler';
|
||||
|
||||
const router: express.Router = express.Router();
|
||||
|
||||
// 不需要会话ID的路由
|
||||
router.get('/connection-ids', handler.getAllConnectionIds);
|
||||
|
||||
// 需要会话ID的路由
|
||||
router.use(handler.checkSessionId);
|
||||
router.get('/connection', handler.getConnection);
|
||||
router.get('/offer', handler.getOffer);
|
||||
router.get('/answer', handler.getAnswer);
|
||||
router.get('/candidate', handler.getCandidate);
|
||||
router.get('', handler.getAll);
|
||||
router.put('', handler.createSession);
|
||||
router.delete('', handler.deleteSession);
|
||||
router.put('/connection', handler.createConnection);
|
||||
router.delete('/connection', handler.deleteConnection);
|
||||
router.post('/offer', handler.postOffer);
|
||||
router.post('/answer', handler.postAnswer);
|
||||
router.post('/candidate', handler.postCandidate);
|
||||
|
||||
export default router;
|
||||
64
src/swagger.ts
Normal file
64
src/swagger.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Swagger配置文件
|
||||
* 用于设置API文档的基本信息和路由
|
||||
*/
|
||||
import * as swaggerJSDoc from 'swagger-jsdoc';
|
||||
import * as swaggerUi from 'swagger-ui-express';
|
||||
import { Express } from 'express';
|
||||
import Options from './class/options';
|
||||
|
||||
/**
|
||||
* 初始化Swagger
|
||||
* @param app Express应用实例
|
||||
* @param config 配置选项
|
||||
*/
|
||||
export const initSwagger = (app: Express, config: Options): void => {
|
||||
// 根据配置生成服务器URL
|
||||
const protocol = config.secure ? 'https' : 'http';
|
||||
const port = config.port || 8080;
|
||||
const serverUrl = `${protocol}://localhost:${port}`;
|
||||
|
||||
/**
|
||||
* Swagger配置选项
|
||||
*/
|
||||
const swaggerOptions = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'WebRTC Signaling API',
|
||||
version: '1.0.0',
|
||||
description: 'WebRTC信令服务器API文档',
|
||||
contact: {
|
||||
name: 'WebRTC Team',
|
||||
email: 'contact@webrtc.example.com'
|
||||
}
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: serverUrl,
|
||||
description: '本地开发服务器'
|
||||
}
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
sessionAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'session-id',
|
||||
description: '会话ID'
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [
|
||||
{
|
||||
sessionAuth: []
|
||||
}
|
||||
]
|
||||
},
|
||||
apis: ['./src/class/httphandler.ts', './src/signaling.ts']
|
||||
};
|
||||
|
||||
const swaggerSpec = swaggerJSDoc(swaggerOptions);
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
console.log(`Swagger文档已初始化,访问 ${serverUrl}/api-docs 查看`);
|
||||
};
|
||||
116
src/websocket.ts
Normal file
116
src/websocket.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import * as websocket from "ws";
|
||||
import { Server } from 'http';
|
||||
import * as handler from "./class/websockethandler";
|
||||
|
||||
export default class WSSignaling {
|
||||
server: Server;
|
||||
wss: websocket.Server;
|
||||
|
||||
/**
|
||||
* 构造函数,初始化WebSocket信令服务器
|
||||
* @param server HTTP服务器实例
|
||||
* @param mode 通信模式(public或private)
|
||||
*/
|
||||
constructor(server: Server, mode: string) {
|
||||
// 保存服务器实例
|
||||
this.server = server;
|
||||
// 创建WebSocket服务器
|
||||
this.wss = new websocket.Server({ server });
|
||||
// 重置处理器,设置通信模式
|
||||
handler.reset(mode);
|
||||
|
||||
/**
|
||||
* 监听WebSocket连接事件
|
||||
* @param ws WebSocket连接实例
|
||||
*/
|
||||
this.wss.on('connection', (ws: WebSocket) => {
|
||||
// 添加新的WebSocket连接到处理器
|
||||
handler.add(ws);
|
||||
//handler.AddHeartbeat(ws);
|
||||
/**
|
||||
* 监听连接关闭事件
|
||||
*/
|
||||
ws.onclose = (): void => {
|
||||
// 从处理器中移除关闭的连接
|
||||
handler.remove(ws);
|
||||
//handler.RemoveHeartbeat(ws);
|
||||
};
|
||||
|
||||
/**
|
||||
* 监听消息事件
|
||||
* @param event 消息事件对象
|
||||
*/
|
||||
ws.onmessage = (event: MessageEvent): void => {
|
||||
// 消息类型说明:
|
||||
// 1. connect, disconnect 消息格式:
|
||||
// { type: "connect", connectionId: "连接ID" }
|
||||
// { type: "disconnect", connectionId: "连接ID" }
|
||||
// 2. offer, answer, candidate 消息格式:
|
||||
// {
|
||||
// type: "offer",
|
||||
// data: {
|
||||
// from: "发送方连接ID",
|
||||
// to: "接收方连接ID",
|
||||
// data: "信令数据"
|
||||
// }
|
||||
// }
|
||||
// 3. broadcast 消息格式:
|
||||
// {
|
||||
// type: "broadcast",
|
||||
// message: "广播消息内容",
|
||||
// targetConnectionId: "目标连接ID(可选)"
|
||||
// }
|
||||
|
||||
// 解析消息数据
|
||||
const msg = JSON.parse(event.data);
|
||||
// 检查消息是否有效
|
||||
if (!msg || !this) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 打印接收到的消息
|
||||
console.log(msg);
|
||||
|
||||
// 根据消息类型处理
|
||||
switch (msg.type) {
|
||||
case "connect":
|
||||
handler.onConnect(ws, msg.connectionId);
|
||||
break;
|
||||
case "disconnect":
|
||||
handler.onDisconnect(ws, msg.connectionId);
|
||||
break;
|
||||
case "offer":
|
||||
if (msg.participantId !== undefined) msg.data.participantId = msg.participantId;
|
||||
handler.onOffer(ws, msg.data);
|
||||
break;
|
||||
case "answer":
|
||||
if (msg.participantId !== undefined) msg.data.participantId = msg.participantId;
|
||||
handler.onAnswer(ws, msg.data);
|
||||
break;
|
||||
case "candidate":
|
||||
if (msg.participantId !== undefined) msg.data.participantId = msg.participantId;
|
||||
handler.onCandidate(ws, msg.data);
|
||||
break;
|
||||
case "ping":
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
case "pong":
|
||||
(ws as any).lastActivity = Date.now();
|
||||
break;
|
||||
case "broadcast":
|
||||
handler.onBroadcast(ws, msg.data);
|
||||
break;
|
||||
case 'call-request':
|
||||
handler.onCallConnectionId(ws, msg.data);
|
||||
break;
|
||||
case 'on-message':
|
||||
if (msg.from) msg.data.connectionId = msg.from;
|
||||
handler.onMessage(ws, msg.data);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
561
src/服务端接口与WebSocket消息类型.md
Normal file
561
src/服务端接口与WebSocket消息类型.md
Normal file
@@ -0,0 +1,561 @@
|
||||
# 服务端接口与 WebSocket 消息类型
|
||||
|
||||
## 一、HTTP REST API 接口
|
||||
|
||||
### 1.1 配置接口
|
||||
|
||||
**GET /config**
|
||||
- **功能**: 获取服务器配置信息
|
||||
- **参数**: 无
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"useWebSocket": boolean,
|
||||
"startupMode": string,
|
||||
"logging": string
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 头像上传接口
|
||||
|
||||
**POST /api/upload/avatar**
|
||||
- **功能**: 上传用户头像
|
||||
- **参数**:
|
||||
- `userId` (body): 用户ID
|
||||
- `avatar` (file): 头像文件
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"success": boolean,
|
||||
"avatarUrl": string,
|
||||
"message": string
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 会话管理接口
|
||||
|
||||
**GET /signaling/connection-ids**
|
||||
- **功能**: 获取所有活跃的连接ID(无需会话认证)
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"connectionIds": string[],
|
||||
"totalCount": number
|
||||
}
|
||||
```
|
||||
|
||||
**PUT /signaling**
|
||||
- **功能**: 创建新的会话,获取会话ID
|
||||
- **参数**: 无
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"sessionId": string
|
||||
}
|
||||
```
|
||||
|
||||
**GET /signaling**
|
||||
- **功能**: 获取当前会话的所有信令消息
|
||||
- **认证**: 需要在请求头 `Session-Id` 中提供会话ID
|
||||
- **参数**:
|
||||
- `fromtime` (query): 起始时间戳,用于增量拉取
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"connectionId": string,
|
||||
"type": "connect|disconnect|offer|answer|candidate",
|
||||
"datetime": number,
|
||||
"sdp": string,
|
||||
"polite": boolean,
|
||||
"candidate": string,
|
||||
"sdpMLineIndex": number,
|
||||
"sdpMid": string
|
||||
}
|
||||
],
|
||||
"datetime": number
|
||||
}
|
||||
```
|
||||
|
||||
**DELETE /signaling**
|
||||
- **功能**: 删除当前会话及其所有连接
|
||||
- **认证**: 需要会话ID
|
||||
- **响应**: 200 OK
|
||||
|
||||
### 1.4 连接管理接口
|
||||
|
||||
**GET /signaling/connection**
|
||||
- **功能**: 获取当前会话的连接列表
|
||||
- **认证**: 需要会话ID
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"connections": [
|
||||
{
|
||||
"connectionId": string,
|
||||
"type": "connect",
|
||||
"datetime": number
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**PUT /signaling/connection**
|
||||
- **功能**: 创建新的连接
|
||||
- **认证**: 需要会话ID
|
||||
- **请求体**:
|
||||
```json
|
||||
{
|
||||
"connectionId": string
|
||||
}
|
||||
```
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"connectionId": string,
|
||||
"polite": boolean,
|
||||
"type": "connect",
|
||||
"datetime": number
|
||||
}
|
||||
```
|
||||
|
||||
**DELETE /signaling/connection**
|
||||
- **功能**: 删除指定的连接
|
||||
- **认证**: 需要会话ID
|
||||
- **请求体**:
|
||||
```json
|
||||
{
|
||||
"connectionId": string
|
||||
}
|
||||
```
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"connectionId": string
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 WebRTC 信令交换接口
|
||||
|
||||
**GET /signaling/offer**
|
||||
- **功能**: 获取 offer 信令消息列表
|
||||
- **认证**: 需要会话ID
|
||||
- **参数**: `fromtime` (query): 起始时间戳
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"offers": [
|
||||
{
|
||||
"connectionId": string,
|
||||
"sdp": string,
|
||||
"polite": boolean,
|
||||
"type": "offer",
|
||||
"datetime": number
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**POST /signaling/offer**
|
||||
- **功能**: 发送 offer 信令
|
||||
- **认证**: 需要会话ID
|
||||
- **请求体**:
|
||||
```json
|
||||
{
|
||||
"connectionId": string,
|
||||
"sdp": string
|
||||
}
|
||||
```
|
||||
- **响应**: 200 OK
|
||||
|
||||
**GET /signaling/answer**
|
||||
- **功能**: 获取 answer 信令消息列表
|
||||
- **认证**: 需要会话ID
|
||||
- **参数**: `fromtime` (query): 起始时间戳
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"answers": [
|
||||
{
|
||||
"connectionId": string,
|
||||
"sdp": string,
|
||||
"type": "answer",
|
||||
"datetime": number
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**POST /signaling/answer**
|
||||
- **功能**: 发送 answer 信令
|
||||
- **认证**: 需要会话ID
|
||||
- **请求体**:
|
||||
```json
|
||||
{
|
||||
"connectionId": string,
|
||||
"sdp": string
|
||||
}
|
||||
```
|
||||
- **响应**: 200 OK
|
||||
|
||||
**GET /signaling/candidate**
|
||||
- **功能**: 获取 ICE candidate 信令消息列表
|
||||
- **认证**: 需要会话ID
|
||||
- **参数**: `fromtime` (query): 起始时间戳
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"connectionId": string,
|
||||
"candidate": string,
|
||||
"sdpMLineIndex": number,
|
||||
"sdpMid": string,
|
||||
"type": "candidate",
|
||||
"datetime": number
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**POST /signaling/candidate**
|
||||
- **功能**: 发送 ICE candidate 信令
|
||||
- **认证**: 需要会话ID
|
||||
- **请求体**:
|
||||
```json
|
||||
{
|
||||
"connectionId": string,
|
||||
"candidate": string,
|
||||
"sdpMLineIndex": number,
|
||||
"sdpMid": string
|
||||
}
|
||||
```
|
||||
- **响应**: 200 OK
|
||||
|
||||
### 1.6 房间信息接口
|
||||
|
||||
**GET /signaling/rooms**
|
||||
- **功能**: 获取房间和用户信息
|
||||
- **认证**: 需要会话ID
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"rooms": [
|
||||
{
|
||||
"roomId": string,
|
||||
"users": [
|
||||
{
|
||||
"sessionId": string,
|
||||
"connected": boolean
|
||||
}
|
||||
],
|
||||
"userCount": number
|
||||
}
|
||||
],
|
||||
"totalRooms": number
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、WebSocket 消息类型
|
||||
|
||||
### 2.1 连接生命周期消息
|
||||
|
||||
#### connect(连接建立)
|
||||
|
||||
- **方向**: 客户端 → 服务端 → 客户端
|
||||
- **客户端发送**:
|
||||
```json
|
||||
{
|
||||
"type": "connect",
|
||||
"connectionId": string
|
||||
}
|
||||
```
|
||||
- **服务端响应**:
|
||||
```json
|
||||
{
|
||||
"type": "connect",
|
||||
"connectionId": string,
|
||||
"polite": boolean,
|
||||
"role": "host|participant",
|
||||
"participantId": string
|
||||
}
|
||||
```
|
||||
- **说明**: 建立连接并协商 polite 标志以处理连接冲突。`polite=true` 表示后加入方(participant),`polite=false` 表示先加入方(host)。
|
||||
|
||||
#### disconnect(连接断开)
|
||||
|
||||
- **方向**: 客户端 → 服务端 → 客户端
|
||||
- **客户端发送**:
|
||||
```json
|
||||
{
|
||||
"type": "disconnect",
|
||||
"connectionId": string
|
||||
}
|
||||
```
|
||||
- **服务端响应**:
|
||||
```json
|
||||
{
|
||||
"type": "disconnect",
|
||||
"connectionId": string,
|
||||
"reason": "normal|host-left"
|
||||
}
|
||||
```
|
||||
- **说明**: 断开连接。当 host 离开时,reason 为 `host-left`。
|
||||
|
||||
#### participant-joined(参与者加入,仅私有模式)
|
||||
|
||||
- **方向**: 服务端 → host 客户端
|
||||
- **格式**:
|
||||
```json
|
||||
{
|
||||
"type": "participant-joined",
|
||||
"connectionId": string,
|
||||
"participantId": string
|
||||
}
|
||||
```
|
||||
- **说明**: 通知 host 有新的 participant 加入。
|
||||
|
||||
#### participant-left(参与者离开,仅私有模式)
|
||||
|
||||
- **方向**: 服务端 → host 客户端 / 其他 participants
|
||||
- **格式**:
|
||||
```json
|
||||
{
|
||||
"type": "participant-left",
|
||||
"connectionId": string,
|
||||
"participantId": string
|
||||
}
|
||||
```
|
||||
- **说明**: 通知有 participant 离开房间。
|
||||
|
||||
### 2.2 WebRTC SDP 交换消息
|
||||
|
||||
#### offer
|
||||
|
||||
- **方向**: 双向
|
||||
- **格式**:
|
||||
```json
|
||||
{
|
||||
"type": "offer",
|
||||
"from": string,
|
||||
"to": string,
|
||||
"data": {
|
||||
"sdp": string,
|
||||
"connectionId": string,
|
||||
"participantId": string
|
||||
},
|
||||
"participantId": string
|
||||
}
|
||||
```
|
||||
- **路由规则**:
|
||||
- **私有模式**: Host → 所有/特定 Participant;Participant → Host
|
||||
- **公共模式**: Peer → 所有其他 Peers
|
||||
|
||||
#### answer
|
||||
|
||||
- **方向**: 双向
|
||||
- **格式**:
|
||||
```json
|
||||
{
|
||||
"type": "answer",
|
||||
"from": string,
|
||||
"to": string,
|
||||
"data": {
|
||||
"sdp": string,
|
||||
"connectionId": string,
|
||||
"participantId": string
|
||||
},
|
||||
"participantId": string
|
||||
}
|
||||
```
|
||||
- **路由规则**:
|
||||
- **私有模式**: Participant → Host;Host → 特定 Participant
|
||||
- **公共模式**: Peer → 特定 Peer
|
||||
|
||||
#### candidate
|
||||
|
||||
- **方向**: 双向
|
||||
- **格式**:
|
||||
```json
|
||||
{
|
||||
"type": "candidate",
|
||||
"from": string,
|
||||
"to": string,
|
||||
"data": {
|
||||
"candidate": string,
|
||||
"sdpMLineIndex": number,
|
||||
"sdpMid": string,
|
||||
"connectionId": string,
|
||||
"participantId": string
|
||||
},
|
||||
"participantId": string
|
||||
}
|
||||
```
|
||||
- **路由规则**: 与 answer 消息相同。
|
||||
|
||||
### 2.3 心跳/控制消息
|
||||
|
||||
#### ping(服务端 → 客户端)
|
||||
|
||||
- **格式**:
|
||||
```json
|
||||
{
|
||||
"from": string,
|
||||
"to": string,
|
||||
"type": "on-message",
|
||||
"data": {
|
||||
"type": "ping"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **说明**: 服务端心跳检测(可选功能,默认未启用)。
|
||||
|
||||
#### pong(客户端 → 服务端)
|
||||
|
||||
- **格式**:
|
||||
```json
|
||||
{
|
||||
"type": "pong"
|
||||
}
|
||||
```
|
||||
- **说明**: 心跳应答。
|
||||
|
||||
### 2.4 自定义消息
|
||||
|
||||
#### on-message(通用消息传递)
|
||||
|
||||
- **方向**: 双向
|
||||
- **格式**:
|
||||
```json
|
||||
{
|
||||
"type": "on-message",
|
||||
"from": string,
|
||||
"to": string,
|
||||
"data": {
|
||||
"message": string|object,
|
||||
"connectionId": string,
|
||||
"senderId": string,
|
||||
"participantId": string
|
||||
}
|
||||
}
|
||||
```
|
||||
- **路由规则**:
|
||||
- **私有模式**: Host ↔ 所有 Participants;Participant ↔ Host
|
||||
- **公共模式**: Peer → Peer
|
||||
- **说明**: 传输文本、数据或聊天消息。
|
||||
|
||||
#### broadcast(广播消息)
|
||||
|
||||
- **客户端发送**:
|
||||
```json
|
||||
{
|
||||
"type": "broadcast",
|
||||
"message": string|object,
|
||||
"targetConnectionId": string
|
||||
}
|
||||
```
|
||||
- **服务端转发**:
|
||||
```json
|
||||
{
|
||||
"type": "broadcast",
|
||||
"message": string|object,
|
||||
"from": "server"
|
||||
}
|
||||
```
|
||||
- **说明**: 若指定 `targetConnectionId`,则广播给该连接组内的所有成员;否则广播给所有连接的客户端。
|
||||
|
||||
### 2.5 呼叫请求消息
|
||||
|
||||
#### call-request
|
||||
|
||||
- **方向**: 客户端 → 服务端 → 客户端
|
||||
- **格式**:
|
||||
```json
|
||||
{
|
||||
"type": "call-request",
|
||||
"data": string
|
||||
}
|
||||
```
|
||||
- **路由规则**:
|
||||
- **私有模式**: Participant → Server → Host
|
||||
- **公共模式**: Peer → Server → 所有其他 Peers
|
||||
- **说明**: 发起呼叫请求。
|
||||
|
||||
---
|
||||
|
||||
## 三、通信模式说明
|
||||
|
||||
### 3.1 公共模式(Public Mode)
|
||||
|
||||
- 所有连接的客户端都可以相互通信
|
||||
- offer/answer/candidate 向所有其他客户端广播
|
||||
|
||||
### 3.2 私有模式(Private Mode)
|
||||
|
||||
- 一个 connectionId 对应一个房间,包含 1 个 host 和多个 participants
|
||||
- Host 是第一个加入 connectionId 的客户端(`polite=false`)
|
||||
- Participants 是后续加入的客户端(`polite=true`)
|
||||
- Host 发送的消息可单播给特定 participant 或广播给所有 participants
|
||||
- Participant 发送的消息仅发送给 host
|
||||
- 当 host 离开时,整个房间关闭,所有 participants 被断开连接
|
||||
- 当 participant 离开时,房间继续存在
|
||||
|
||||
---
|
||||
|
||||
## 四、会话与连接管理
|
||||
|
||||
### 4.1 会话(Session)
|
||||
|
||||
- 每个客户端通过 `PUT /signaling` 创建一个唯一的会话
|
||||
- 会话ID通过 `Session-Id` 请求头在所有后续 HTTP 请求中传递
|
||||
- 会话管理超时:10 秒无请求则自动删除会话
|
||||
- 一个会话可以包含多个连接ID
|
||||
|
||||
### 4.2 连接(Connection)
|
||||
|
||||
- 连接ID由客户端生成和指定
|
||||
- 在 HTTP 模式下,连接对通过 connectionId 自动配对
|
||||
- 在 WebSocket 私有模式下,第一个连接是 host,后续连接是 participants
|
||||
- 一个连接对在私有模式下支持 1 对多(1 host + N participants)
|
||||
|
||||
### 4.3 Polite 标志
|
||||
|
||||
- 用于处理 WebRTC 连接冲突(双方同时发送 offer 的情况)
|
||||
- `polite=true`:该端应放弃 offer,接收来自另一端的 offer
|
||||
- `polite=false`:该端具有优先权,可以发送 offer
|
||||
- 私有模式下:host → `polite=false`,participants → `polite=true`
|
||||
|
||||
---
|
||||
|
||||
## 五、关键设计特性
|
||||
|
||||
1. **双协议支持**: 同时支持 HTTP 轮询和 WebSocket 两种信令协议
|
||||
2. **两种通信模式**: 公共模式(全连通)和私有模式(1 对多房间)
|
||||
3. **Polite 机制**: 自动处理并发 offer 冲突
|
||||
4. **会话隔离**: 通过 Session-ID 在 HTTP 模式下隔离不同客户端的消息
|
||||
5. **心跳检测**: 可选的心跳机制防止连接超时
|
||||
6. **消息增量拉取**: HTTP GET 支持 fromtime 参数实现增量消息获取
|
||||
7. **头像上传**: 内置文件上传功能支持用户头像管理
|
||||
8. **API 文档**: 集成 Swagger 提供自动生成的 API 文档
|
||||
|
||||
---
|
||||
|
||||
## 六、源文件路径
|
||||
|
||||
| 文件 | 功能 |
|
||||
|------|------|
|
||||
| `src/index.ts` | 应用入口,配置和启动服务器 |
|
||||
| `src/server.ts` | Express 服务器创建,中间件配置 |
|
||||
| `src/signaling.ts` | HTTP REST 路由定义 |
|
||||
| `src/websocket.ts` | WebSocket 服务器和消息路由 |
|
||||
| `src/class/httphandler.ts` | HTTP 处理器,核心业务逻辑 |
|
||||
| `src/class/websockethandler.ts` | WebSocket 处理器,消息分发逻辑 |
|
||||
| `src/class/offer.ts` | Offer 类定义 |
|
||||
| `src/class/answer.ts` | Answer 类定义 |
|
||||
| `src/class/candidate.ts` | Candidate 类定义 |
|
||||
| `src/class/options.ts` | 配置选项接口 |
|
||||
| `src/swagger.ts` | Swagger API 文档配置 |
|
||||
| `src/log.ts` | 日志工具 |
|
||||
Reference in New Issue
Block a user