初始化

This commit is contained in:
2026-04-29 15:18:30 +08:00
commit e47eee39ed
111 changed files with 44168 additions and 0 deletions

8
src/class/answer.ts Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

10
src/class/offer.ts Normal file
View 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
View File

@@ -0,0 +1,9 @@
export default interface Options {
secure?: boolean;
port?: number;
keyfile?: string;
certfile?: string;
type?: string;
mode?: string;
logging?: string;
}

View 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
* 值: ConnectionGroup1个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转发给所有participantsparticipant的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消息中获取目标participantIdhost回复时指定
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转发给所有participantsparticipant的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的消息转发给所有participantsparticipant的消息转发给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
View 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
View 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
View 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
View 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
View 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
View 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;
}
};
});
}
}

View 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 → 所有/特定 ParticipantParticipant → Host
- **公共模式**: Peer → 所有其他 Peers
#### answer
- **方向**: 双向
- **格式**:
```json
{
"type": "answer",
"from": string,
"to": string,
"data": {
"sdp": string,
"connectionId": string,
"participantId": string
},
"participantId": string
}
```
- **路由规则**:
- **私有模式**: Participant → HostHost → 特定 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 ↔ 所有 ParticipantsParticipant ↔ 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` | 日志工具 |