From ef92ca77fae7555ab3cfe6a37a582027782fce4e Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Wed, 13 Aug 2025 17:14:39 +0100 Subject: [PATCH 01/31] implement mediasoup server --- src/servers/mediasoup-server.ts | 227 ++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 src/servers/mediasoup-server.ts diff --git a/src/servers/mediasoup-server.ts b/src/servers/mediasoup-server.ts new file mode 100644 index 0000000..3fdb9ed --- /dev/null +++ b/src/servers/mediasoup-server.ts @@ -0,0 +1,227 @@ +import { + createWorker, + types as mediasoupTypes, + observer as mediasoupObserver, +} from 'mediasoup'; +import config from '../config'; + +class MediasoupServer { + private static instance: MediasoupServer | null; + private workers: Map; + private workerLoads: Map; + private isRunning: boolean; + + private constructor() { + this.workers = new Map(); + this.workerLoads = new Map(); + this.isRunning = false; + this.observe(); + this.createWorkers(); + } + + static getInstance(): MediasoupServer { + if (!MediasoupServer.instance) + MediasoupServer.instance = new MediasoupServer(); + + return MediasoupServer.instance; + } + + private async createWorkers(): Promise { + try { + if (this.isRunning) { + return console.log('Mediasoup Workers is already created'); + } + + for (let i = 0; i < config.cpus; i++) { + const worker = await createWorker(config.mediasoup.workerSettings); + worker.once('died', () => { + console.error('Worker died', { workerId: worker.pid }); + setTimeout(() => process.exit(1), 2000); + }); + + this.workers.set(worker.pid, worker); + this.workerLoads.set(worker.pid, 0); + } + } catch (error) { + console.error('Worker start error! \n', error); + process.exit(1); + } + } + + increaseWorkerLoad(workerPid: number): void { + if (!this.isRunning) { + console.log('SerStart Mediasoup worker first'); + return; + } + if (this.workerLoads.get(workerPid)) { + this.workerLoads.set( + workerPid, + (this.workerLoads.get(workerPid) as number) + 1 + ); + } + } + + decreaseWorkerLoad(workerPid: number): void { + if (!this.isRunning) { + console.log('Start Mediasoup worker first'); + return; + } + if (this.workerLoads.get(workerPid)) { + this.workerLoads.set( + workerPid, + (this.workerLoads.get(workerPid) as number) - 1 + ); + } + } + + getLeastLoadedWorker(): mediasoupTypes.Worker | undefined { + const sortedWorkerLoads = new Map( + [...this.workerLoads.entries()].sort((a, b) => a[1] - b[1]) + ); + const workerId = sortedWorkerLoads.keys().next().value; + if (!workerId) return; + return this.workers.get(workerId); + } + + async getRouterRtpCapabilities(): Promise { + try { + if (!this.isRunning) { + throw 'Start Mediasoup worker first'; + } + const worker = Array.from(this.workers.values())[0]; + + const router = await worker.createRouter({ + mediaCodecs: config.mediasoup.routerMediaCodecs, + }); + const routerRtpCapabilities = router.rtpCapabilities; + router.close(); + return routerRtpCapabilities; + } catch (error) { + console.error('getRouterRtpCapabilities error', { error }); + throw error; + } + } + + private observe(): void { + mediasoupObserver.on('newworker', worker => { + worker.appData.routers = new Map(); + worker.appData.transports = new Map(); + worker.appData.producers = new Map(); + worker.appData.consumers = new Map(); + worker.appData.dataProducers = new Map(); + worker.appData.dataConsumers = new Map(); + worker.appData.load = 0; + + worker.observer.on('close', () => { + console.info('Worker closed'); + }); + + worker.observer.on('newrouter', router => { + router.appData.transports = new Map(); + router.appData.producers = new Map(); + router.appData.consumers = new Map(); + router.appData.dataProducers = new Map(); + router.appData.dataConsumers = new Map(); + + router.appData.worker = worker; + (worker.appData.routers as Map).set( + router.id, + router + ); + + router.observer.on('close', () => { + (worker.appData.routers as Map).delete( + router.id + ); + }); + router.observer.on('newtransport', transport => { + transport.appData.producers = new Map(); + transport.appData.consumers = new Map(); + transport.appData.dataProducers = new Map(); + transport.appData.dataConsumers = new Map(); + + transport.appData.router = router; + ( + router.appData.transports as Map + ).set(transport.id, transport); + ( + worker.appData.transports as Map + ).set(transport.id, transport); + + transport.observer.on('close', () => { + ( + router.appData.transports as Map + ).delete(transport.id); + ( + worker.appData.transports as Map + ).delete(transport.id); + }); + + transport.observer.on('newproducer', producer => { + producer.appData.transport = producer; + ( + transport.appData.producers as Map< + string, + mediasoupTypes.Producer + > + ).set(producer.id, producer); + ( + router.appData.producers as Map + ).set(producer.id, producer); + ( + worker.appData.producers as Map + ).set(producer.id, producer); + + producer.observer.on('close', () => { + ( + transport.appData.producers as Map< + string, + mediasoupTypes.Producer + > + ).delete(producer.id); + ( + router.appData.producers as Map + ).delete(producer.id); + ( + worker.appData.producers as Map + ).delete(producer.id); + }); + }); + + transport.observer.on('newconsumer', consumer => { + consumer.appData.transport = consumer; + ( + transport.appData.consumers as Map< + string, + mediasoupTypes.Consumer + > + ).set(consumer.id, consumer); + ( + router.appData.consumers as Map + ).set(consumer.id, consumer); + ( + worker.appData.consumers as Map + ).set(consumer.id, consumer); + + consumer.observer.on('close', () => { + ( + transport.appData.consumers as Map< + string, + mediasoupTypes.Consumer + > + ).delete(consumer.id); + ( + router.appData.consumers as Map + ).delete(consumer.id); + ( + worker.appData.consumers as Map + ).delete(consumer.id); + }); + }); + }); + }); + }); + } +} + +export const mediaSoupServer = MediasoupServer.getInstance(); From 1e450fadaf2eb132a248810e3a9bba3c7149f6a2 Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Wed, 13 Aug 2025 21:46:20 +0100 Subject: [PATCH 02/31] implement peer service --- src/services/peer.ts | 151 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/services/peer.ts diff --git a/src/services/peer.ts b/src/services/peer.ts new file mode 100644 index 0000000..6c50878 --- /dev/null +++ b/src/services/peer.ts @@ -0,0 +1,151 @@ +import EventEmitter from 'events'; +import { types as mediasoupTypes } from 'mediasoup'; +import { PeerType, ProducerSource } from '../types/interfaces'; +import { mediaSoupServer } from '../servers/mediasoup-server'; + +class Peer extends EventEmitter { + id: string; + roomId: string; + closed: boolean; + signalNodeId: string; + type: PeerType; + + rtpCapabilities: mediasoupTypes.RtpCapabilities; + transports: Map; + producers: Map; + consumers: Map; + + router: mediasoupTypes.Router; + workerPid: number; + + static peers = new Map(); + + constructor({ + id, + roomId, + router, + rtpCapabilities, + signalNodeId, + type, + }: { + id: string; + roomId: string; + router: mediasoupTypes.Router; + rtpCapabilities: mediasoupTypes.RtpCapabilities; + signalNodeId: string; + type: PeerType; + }) { + super(); + this.id = id; + this.roomId = roomId; + this.closed = false; + this.rtpCapabilities = rtpCapabilities; + this.router = router; + this.workerPid = (router.appData.worker as mediasoupTypes.Worker).pid; + this.transports = new Map(); + this.producers = new Map(); + this.consumers = new Map(); + this.signalNodeId = signalNodeId; + this.type = type; + // increment worker load + } + + close(): void { + this.closed = true; + + for (const consumer of this.consumers.values()) { + consumer.close(); + } + for (const producer of this.producers.values()) { + producer.close(); + } + for (const transport of this.transports.values()) { + transport.close(); + } + + this.transports.clear(); + this.producers.clear(); + this.consumers.clear(); + + mediaSoupServer.decreaseWorkerLoad(this.workerPid); + + this.removeAllListeners(); // Prevent potential memory leaks + console.info('Peer closed'); + } + + // transport methods + addTransport(transport: mediasoupTypes.WebRtcTransport): void { + this.transports.set(transport.id, transport); + transport.observer.on('close', () => { + this.transports.delete(transport.id); + }); + } + + getTransport(id: string): mediasoupTypes.WebRtcTransport | undefined { + return this.transports.get(id); + } + + removeTransport(id: string): void { + this.transports.delete(id); + } + + // Producer methods + addProducer(producer: mediasoupTypes.Producer): void { + this.producers.set(producer.id, producer); + producer.observer.on('close', () => { + this.producers.delete(producer.id); + }); + } + + getProducer(id: string): mediasoupTypes.Producer | undefined { + return this.producers.get(id); + } + + removeProducer(id: string): void { + this.producers.delete(id); + } + + getProducersBySource(source: ProducerSource): mediasoupTypes.Producer[] { + const allProducers = Array.from(this.producers.values()); + const producers = allProducers.filter( + producer => producer.appData.source === source + ); + return producers; + } + + // Consumers methods + addConsumer(consumer: mediasoupTypes.Consumer): void { + this.consumers.set(consumer.id, consumer); + consumer.observer.on('close', () => { + this.consumers.delete(consumer.id); + }); + } + + getConsumer(id: string): mediasoupTypes.Consumer | undefined { + return this.consumers.get(id); + } + + getConsumerByProducerId( + producerId: string + ): mediasoupTypes.Consumer | undefined { + const allConsumers = Array.from(this.consumers.values()); + const consumer = allConsumers.find( + consumer => consumer.producerId === producerId + ); + return consumer; + } + + getConsumersBySource(source: ProducerSource): mediasoupTypes.Consumer[] { + const allConsumers = Array.from(this.consumers.values()); + const consumer = allConsumers.filter( + consumer => consumer.appData.source === source + ); + return consumer; + } + + removeConsumer(id: string): void { + this.consumers.delete(id); + } +} + +export default Peer; From 94b1b4f3c4a89e43becea77d13cfc1feb92b819e Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Wed, 13 Aug 2025 21:46:47 +0100 Subject: [PATCH 03/31] update config --- src/app.ts | 8 +--- src/config/index.ts | 96 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/app.ts b/src/app.ts index 33d917c..39f2bac 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,3 @@ -import fs from 'fs'; import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; @@ -9,18 +8,13 @@ import { Routes } from './routes'; import { redisServer } from './servers/redis-server'; import { grpcServer } from './servers/grpc-server'; -const serverOption = { - key: fs.readFileSync(config.tls.key, 'utf8'), - cert: fs.readFileSync(config.tls.cert, 'utf8'), -}; - const app = express(); app.use(cors(config.cors)); app.use(helmet()); app.use(express.json()); app.use('/', Routes); -const httpsServer = createServer(serverOption, app); +const httpsServer = createServer(config.httpsServerOptions, app); (async (): Promise => { try { diff --git a/src/config/index.ts b/src/config/index.ts index d2913a1..824f360 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,10 +1,17 @@ +import fs from 'fs'; +import os from 'os'; import path from 'path'; + import * as dotenv from 'dotenv'; +import { types as mediasoupTypes } from 'mediasoup'; dotenv.config(); -const certPath = path.join(__dirname, '..', 'certs', 'fullchain.pem'); -const keyPath = path.join(__dirname, '..', 'certs', 'privkey.pem'); +const certFile = + process.env.HTTPS_CERT || + path.join(__dirname, '..', 'certs', 'fullchain.pem'); +const keyFile = + process.env.HTTPS_KEY || path.join(__dirname, '..', 'certs', 'privkey.pem'); const config = { env: process.env.NODE_ENV, @@ -12,15 +19,94 @@ const config = { origin: process.env.NODE_ENV === 'production' ? ['https://mitsi.app'] : '*', methods: ['GET', 'POST'], }, - tls: { - cert: process.env.HTTPS_CERT || certPath, - key: process.env.HTTPS_KEY || keyPath, + httpsServerOptions: { + key: fs.readFileSync(keyFile, 'utf8'), + cert: fs.readFileSync(certFile, 'utf8'), }, port: process.env.PORT || 4000, + cpus: Object.keys(os.cpus()).length, + apiServerUrl: process.env.API_SERVER_URL, apiServerApiKey: process.env.API_SERVER_API_KEY, recordingServerUrl: process.env.RECORDING_SERVER_URL, redisServerUrl: process.env.REDIS_SERVER_URL || 'redis://localhost:6379', + + mediasoup: { + workerSettings: { + dtlsCertificateFile: certFile, + dtlsPrivateKeyFile: keyFile, + rtcMinPort: parseInt(process.env.RTC_MIN_PORT || '2000'), + rtcMaxPort: parseInt(process.env.RTC_MAX_PORT || '2300'), + logLevel: 'warn' as mediasoupTypes.WorkerLogLevel, + logTags: [ + 'info', + 'ice', + 'dtls', + 'rtp', + 'srtp', + 'rtcp', + 'rtx', + 'bwe', + 'score', + 'simulcast', + 'svc', + 'sctp', + ] as Array, + }, + routerMediaCodecs: [ + { + kind: 'audio', + mimeType: 'audio/opus', + clockRate: 48000, + channels: 2, + // parameters: { + // 'stereo': 1, + // 'sprop-stereo': 1, + // 'maxplaybackrate': 48000, + // 'useinbandfec': 1 + // } + }, + { + kind: 'video', + mimeType: 'video/VP8', + clockRate: 90000, + parameters: { + 'x-google-start-bitrate': 1000, + }, + }, + // { + // kind: 'video', + // mimeType: 'video/VP9', + // clockRate: 90000, + // parameters: { + // 'profile-id': 2, + // 'x-google-start-bitrate': 1000 + // } + // }, + // { + // kind: 'video', + // mimeType: 'video/h264', + // clockRate: 90000, + // parameters: { + // 'packetization-mode': 1, + // 'profile-level-id': '4d0032', + // 'level-asymmetry-allowed': 1, + // 'x-google-start-bitrate': 1000 + // } + // }, + // { + // kind: 'video', + // mimeType: 'video/h264', + // clockRate: 90000, + // parameters: { + // 'packetization-mode': 1, + // 'profile-level-id': '42e01f', + // 'level-asymmetry-allowed': 1, + // 'x-google-start-bitrate': 1000 + // } + // } + ] as Array, + }, }; export default config; From cec94face1981821825e306c4439eb7ae391af01 Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Wed, 13 Aug 2025 21:47:00 +0100 Subject: [PATCH 04/31] update interface --- src/types/interfaces.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/interfaces.ts b/src/types/interfaces.ts index 76fe77f..d2c57f7 100644 --- a/src/types/interfaces.ts +++ b/src/types/interfaces.ts @@ -6,7 +6,7 @@ export type AckCallback = (res: { error?: Error | unknown | null; response?: T; }) => void; -export type PeerType = 'Recorder' | 'Attendee'; +export type PeerType = 'Recorder' | 'Participant'; export enum Role { Moderator = 'Moderator', From 3c182d742a9b9bd456699d783ec1c9fc7b9a05e8 Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Thu, 14 Aug 2025 12:24:39 +0100 Subject: [PATCH 05/31] add mediasoup router settings, transport and webrtc options --- src/config/index.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/config/index.ts b/src/config/index.ts index 824f360..4d04a6c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -13,6 +13,9 @@ const certFile = const keyFile = process.env.HTTPS_KEY || path.join(__dirname, '..', 'certs', 'privkey.pem'); +const LISTEN_IP = process.env.LISTEN_IP || '0.0.0.0'; +const ANNOUNCED_ADDRESS = process.env.ANNOUNCED_ADDRESS || '127.0.0.1'; + const config = { env: process.env.NODE_ENV, cors: { @@ -53,6 +56,20 @@ const config = { 'sctp', ] as Array, }, + webRtcServer: { + listenInfos: [ + { + protocol: 'udp', + ip: LISTEN_IP, + announcedAddress: ANNOUNCED_ADDRESS, + }, + { + protocol: 'tcp', + ip: LISTEN_IP, + announcedAddress: ANNOUNCED_ADDRESS, + }, + ] as Array, + }, routerMediaCodecs: [ { kind: 'audio', @@ -106,6 +123,12 @@ const config = { // } // } ] as Array, + + transportListenInfo: { + protocol: 'udp', + ip: LISTEN_IP, + announcedAddress: ANNOUNCED_ADDRESS, + } as mediasoupTypes.TransportListenInfo, }, }; From 185a4dc9d2b32c9e866b326cdca241f71ef7327a Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Thu, 14 Aug 2025 12:34:09 +0100 Subject: [PATCH 06/31] add serverid in config --- src/config/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/index.ts b/src/config/index.ts index 4d04a6c..3471e55 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -17,6 +17,7 @@ const LISTEN_IP = process.env.LISTEN_IP || '0.0.0.0'; const ANNOUNCED_ADDRESS = process.env.ANNOUNCED_ADDRESS || '127.0.0.1'; const config = { + serverId: crypto.randomUUID(), env: process.env.NODE_ENV, cors: { origin: process.env.NODE_ENV === 'production' ? ['https://mitsi.app'] : '*', From 2a932920d4df929fff4d234b17ebdf3aab53feeb Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Thu, 14 Aug 2025 12:34:41 +0100 Subject: [PATCH 07/31] implement getkey to get redis key --- src/types/helpers.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/types/helpers.ts diff --git a/src/types/helpers.ts b/src/types/helpers.ts new file mode 100644 index 0000000..1878036 --- /dev/null +++ b/src/types/helpers.ts @@ -0,0 +1,8 @@ +export const getKey = { + room: (roomId: string): string => `room-${roomId}`, + lobby: (roomId: string): string => `lobby-${roomId}`, + roomPeers: (roomId: string): string => `room-${roomId}-peers`, + roomPeerIds: (roomId: string): string => `room-${roomId}-peerids`, + roomActiveSpeakerPeerId: (roomId: string): string => + `room-${roomId}-active-speaker-peerid`, +}; From cd63f6949c3158fb5b1b541c9c15e77304b96c2c Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Thu, 14 Aug 2025 12:35:05 +0100 Subject: [PATCH 08/31] add transport connection params --- src/types/interfaces.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/types/interfaces.ts b/src/types/interfaces.ts index d2c57f7..bf02fff 100644 --- a/src/types/interfaces.ts +++ b/src/types/interfaces.ts @@ -1,3 +1,5 @@ +import { types as mediasoupTypes } from 'mediasoup'; + export type ProducerSource = 'mic' | 'camera' | 'screen' | 'screenAudio'; export type TransportKind = 'producer' | 'consumer'; @@ -128,3 +130,28 @@ export interface Reaction { position: `${number}%`; timestamp: number; } + +export interface PipeConsumerParams { + producerId: string; + kind: mediasoupTypes.MediaKind; + producerPaused: boolean; + rtpParameters: mediasoupTypes.RtpParameters; + sendTranportId: string; + roomId: string; + recvMediaNodeId: string; + sendMediaNodeId: string; + producerPeerId: string; + appData: mediasoupTypes.AppData; +} + +export interface TransportConnectionParams { + routerId: string; + transportId: string; + sendTransportId?: string; + recvTransportId?: string; + ip: string; + port: number; + srtpParameters?: mediasoupTypes.SrtpParameters; +} + +// WorkerData, RouterData, TransportData, ConsumerData, Producer From cfda33f4887f03406d2692309278d1d8ef2675d2 Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sat, 16 Aug 2025 23:10:09 +0100 Subject: [PATCH 09/31] register and unregister medianode --- package-lock.json | 386 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + src/app.ts | 12 ++ src/lib/utils.ts | 36 +++++ 4 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 src/lib/utils.ts diff --git a/package-lock.json b/package-lock.json index 22449b5..b0b7bce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "express": "^5.1.0", "helmet": "^8.1.0", "mediasoup": "^3.18.0", + "public-ip": "^7.0.1", "redis": "^5.8.0", "zod": "^4.0.17" }, @@ -1482,6 +1483,12 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1688,6 +1695,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -1708,6 +1727,18 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -1862,6 +1893,12 @@ "@types/send": "*" } }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "license": "MIT" + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -2879,6 +2916,33 @@ "node": ">= 0.8" } }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "10.2.14", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", + "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.0.2", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3118,6 +3182,21 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clone-regexp": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-3.0.0.tgz", + "integrity": "sha512-ujdnoq2Kxb8s3ItNBtnYeXdm07FcU0u8ARAT1lQ2YdMwQC+cdiXX8KoqMVuglztILivceTtp4ivqGSmEmhBUJw==", + "license": "MIT", + "dependencies": { + "is-regexp": "^3.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -3191,6 +3270,18 @@ "node": ">= 0.6" } }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3277,6 +3368,33 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", @@ -3309,6 +3427,15 @@ "node": ">=0.10.0" } }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3338,6 +3465,30 @@ "node": ">=0.3.1" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dns-socket": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/dns-socket/-/dns-socket-4.2.2.tgz", + "integrity": "sha512-BDeBd8najI4/lS00HSKpdFia+OvUMytaVjfzR9n5Lq8MlZRSvtbI+uLtx1+XmQFls5wFU9dssccTmQQ6nfpjdg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.4" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/dotenv": { "version": "17.2.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", @@ -4072,6 +4223,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data-encoder": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", + "license": "MIT", + "engines": { + "node": ">= 14.17" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -4133,6 +4293,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-0.1.1.tgz", + "integrity": "sha512-0NVVC0TaP7dSTvn1yMiy6d6Q8gifzbvQafO46RtLG/kHJUBNd+pVRGOBoK44wNBvtSPUJRfdVvkFdD3p0xvyZg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4203,7 +4375,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4271,6 +4442,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz", + "integrity": "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4373,6 +4569,12 @@ "dev": true, "license": "MIT" }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -4398,6 +4600,19 @@ "node": ">= 0.8" } }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4511,6 +4726,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/ip-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz", + "integrity": "sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4582,6 +4809,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-ip": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-5.0.1.tgz", + "integrity": "sha512-FCsGHdlrOnZQcp0+XT5a+pYowf33itBalCl+7ovNXC/7o5BhIpG14M3OrpPPdBSIQJCm+0M5+9mO7S9VVTTCFw==", + "license": "MIT", + "dependencies": { + "ip-regex": "^5.0.0", + "super-regex": "^0.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4598,6 +4841,18 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -5345,7 +5600,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { @@ -5386,7 +5640,6 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -5465,6 +5718,18 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5636,6 +5901,18 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -5881,6 +6158,18 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-url": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.2.tgz", + "integrity": "sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -5970,6 +6259,15 @@ "node": ">= 0.8.0" } }, + "node_modules/p-cancelable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6333,6 +6631,23 @@ "dev": true, "license": "MIT" }, + "node_modules/public-ip": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/public-ip/-/public-ip-7.0.1.tgz", + "integrity": "sha512-DdNcqcIbI0wEeCBcqX+bmZpUCvrDMJHXE553zgyG1MZ8S1a/iCCxmK9iTjjql+SpHSv4cZkmRv5/zGYW93AlCw==", + "license": "MIT", + "dependencies": { + "dns-socket": "^4.2.2", + "got": "^13.0.0", + "is-ip": "^5.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6396,6 +6711,18 @@ ], "license": "MIT" }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6465,6 +6792,12 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -6498,6 +6831,21 @@ "node": ">=4" } }, + "node_modules/responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -6996,6 +7344,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/super-regex": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-0.2.0.tgz", + "integrity": "sha512-WZzIx3rC1CvbMDloLsVw0lkZVKJWbrkJ0k1ghKFmcnPrW1+jWbgTkTEWVtD9lMdmI4jZEz40+naBxl1dCUhXXw==", + "license": "MIT", + "dependencies": { + "clone-regexp": "^3.0.0", + "function-timeout": "^0.1.0", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -7112,6 +7477,21 @@ "node": "*" } }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index 4f58a05..a2d2b8d 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "express": "^5.1.0", "helmet": "^8.1.0", "mediasoup": "^3.18.0", + "public-ip": "^7.0.1", "redis": "^5.8.0", "zod": "^4.0.17" } diff --git a/src/app.ts b/src/app.ts index 39f2bac..c60eca2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,8 @@ import config from './config'; import { Routes } from './routes'; import { redisServer } from './servers/redis-server'; import { grpcServer } from './servers/grpc-server'; +import { MediaNodeData } from './types'; +import { getRedisKey, registerMediaNode } from './lib/utils'; const app = express(); app.use(cors(config.cors)); @@ -16,12 +18,16 @@ app.use('/', Routes); const httpsServer = createServer(config.httpsServerOptions, app); +let medianodeData: MediaNodeData; + (async (): Promise => { try { await redisServer.connect(); httpsServer.listen(config.port, () => { console.log(`Server running on port ${config.port}`); }); + medianodeData = await registerMediaNode(); + console.log('Register medianode'); await grpcServer.start(); } catch (error) { console.error('Initialization error:', error); @@ -31,6 +37,12 @@ const httpsServer = createServer(config.httpsServerOptions, app); const shutdown = async (): Promise => { try { + await redisServer.sRem( + getRedisKey['medianodesRunning'](), + JSON.stringify(medianodeData) + ); + console.log('Delete medianode'); + await redisServer.disconnect(); httpsServer.close(); console.log('Application shut down gracefully'); diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..6d26939 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,36 @@ +import config from '../config'; +import { redisServer } from '../servers/redis-server'; +import { MediaNodeData } from '../types'; + +export const getRedisKey = { + room: (roomId: string): string => `room-${roomId}`, + lobby: (roomId: string): string => `lobby-${roomId}`, + roomPeers: (roomId: string): string => `room-${roomId}-peers`, + roomPeerIds: (roomId: string): string => `room-${roomId}-peerids`, + roomActiveSpeakerPeerId: (roomId: string): string => + `room-${roomId}-activespeakerpeerid`, + roomsOngoing: (): string => `rooms-ongoing`, + medianodesRunning: (): string => `medianodes-running`, + signalnodesRunning: (): string => `signalnodes-running`, + roomMedianodes: (roomId: string): string => `room-${roomId}-medianodes`, + roomSignalnodes: (roomId: string): string => `room-${roomId}-signalnodes`, +}; + +export const registerMediaNode = async (): Promise => { + try { + const { publicIpv4 } = await import('public-ip'); + const ip = await publicIpv4(); + const medianodeData: MediaNodeData = { + id: ip || config.serverId, + ip, + address: `${config.port}`, + }; + await redisServer.sAdd( + getRedisKey['medianodesRunning'](), + JSON.stringify(medianodeData) + ); + return medianodeData; + } catch (error) { + throw error; + } +}; From 731c2ec6629a446d7e682c712e1d2c42fde76733 Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sat, 16 Aug 2025 23:10:37 +0100 Subject: [PATCH 10/31] create webrtc server --- src/servers/mediasoup-server.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/servers/mediasoup-server.ts b/src/servers/mediasoup-server.ts index 3fdb9ed..4607c40 100644 --- a/src/servers/mediasoup-server.ts +++ b/src/servers/mediasoup-server.ts @@ -34,6 +34,7 @@ class MediasoupServer { for (let i = 0; i < config.cpus; i++) { const worker = await createWorker(config.mediasoup.workerSettings); + worker.createWebRtcServer(config.mediasoup.webRtcServer); worker.once('died', () => { console.error('Worker died', { workerId: worker.pid }); setTimeout(() => process.exit(1), 2000); @@ -74,6 +75,10 @@ class MediasoupServer { } } + getWorkers(): mediasoupTypes.Worker[] { + return Array.from(this.workers.values()); + } + getLeastLoadedWorker(): mediasoupTypes.Worker | undefined { const sortedWorkerLoads = new Map( [...this.workerLoads.entries()].sort((a, b) => a[1] - b[1]) @@ -110,19 +115,23 @@ class MediasoupServer { worker.appData.consumers = new Map(); worker.appData.dataProducers = new Map(); worker.appData.dataConsumers = new Map(); + worker.appData.webRtcServer = null; worker.appData.load = 0; worker.observer.on('close', () => { console.info('Worker closed'); }); - + worker.observer.on('newwebrtcserver', webRtcServer => { + console.log('newwebrtcserver created'); + worker.appData.webRtcServer = webRtcServer; + }); worker.observer.on('newrouter', router => { router.appData.transports = new Map(); router.appData.producers = new Map(); router.appData.consumers = new Map(); router.appData.dataProducers = new Map(); router.appData.dataConsumers = new Map(); - + router.appData.webRtcServer = worker.appData.webRtcServer; router.appData.worker = worker; (worker.appData.routers as Map).set( router.id, From 764723c8feeb3adc34eace7ebe734209184816d7 Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sat, 16 Aug 2025 23:10:57 +0100 Subject: [PATCH 11/31] rename file --- src/types/helpers.ts | 8 -- src/types/interfaces.ts | 157 ---------------------------------------- 2 files changed, 165 deletions(-) delete mode 100644 src/types/helpers.ts delete mode 100644 src/types/interfaces.ts diff --git a/src/types/helpers.ts b/src/types/helpers.ts deleted file mode 100644 index 1878036..0000000 --- a/src/types/helpers.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const getKey = { - room: (roomId: string): string => `room-${roomId}`, - lobby: (roomId: string): string => `lobby-${roomId}`, - roomPeers: (roomId: string): string => `room-${roomId}-peers`, - roomPeerIds: (roomId: string): string => `room-${roomId}-peerids`, - roomActiveSpeakerPeerId: (roomId: string): string => - `room-${roomId}-active-speaker-peerid`, -}; diff --git a/src/types/interfaces.ts b/src/types/interfaces.ts deleted file mode 100644 index bf02fff..0000000 --- a/src/types/interfaces.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { types as mediasoupTypes } from 'mediasoup'; - -export type ProducerSource = 'mic' | 'camera' | 'screen' | 'screenAudio'; - -export type TransportKind = 'producer' | 'consumer'; -export type AckCallback = (res: { - status: 'success' | 'error'; - error?: Error | unknown | null; - response?: T; -}) => void; -export type PeerType = 'Recorder' | 'Participant'; - -export enum Role { - Moderator = 'Moderator', - Speaker = 'Speaker', - Participant = 'Participant', -} - -export enum Tag { - Host = 'Host', - Cohost = 'Co-host', - Moderator = 'Moderator', - Speaker = 'Speaker', - Pinned = 'Pinned', - Participant = 'Participant', -} - -export enum HTTPSTATUS { - OK = 200, - CREATED = 201, - BAD_REQUEST = 400, - UNAUTHORISED = 401, - FORBIDDEN = 403, - NOT_FOUND = 404, - CONFLICT = 409, - INTERNAL_SERVER_ERROR = 500, -} - -export interface HandState { - raised: boolean; - timestamp?: number; -} - -export interface MessageData { - event: string; - args: { [key: string]: unknown }; -} - -export interface RoomData { - id: string; - title: string; - roomId: string; - description?: string; - host: { - id: string; - name: string; - }; - coHostEmails: string[]; - guestEmails: string[]; - allowWaiting?: boolean; -} - -export interface RoomInstanceData { - roomId: string; - hostId: string; - coHostEmails: string[]; - started: number; - maxDuration: number; - maxPeers: number; - allowRecording: boolean; - allowWaiting: boolean; - activeSpeakerPeerId?: string | null; - recording: boolean; - timeLeft?: number; - isFull?: boolean; -} - -export interface PeerData { - id: string; - userId?: string; - name: string; - email?: string; - photo?: string; - color?: string; - isMobileDevice?: boolean; - jobTitle?: string; - isRejoining?: boolean; - isRecorder?: boolean; - hand?: HandState; - roles?: Role[]; - tag?: Tag; - pinned?: boolean; - online?: boolean; - joined?: number; - reconnecting?: boolean; -} - -export interface AttendeeData { - id: string; - name: string; - userId?: string; - photo?: string; - color?: string; - email?: string; - info?: string; - joined: number; -} - -export interface ChatData { - id: string; - text: string; - sender: PeerData; - receiver: PeerData; - isFile?: boolean; - isPinned?: boolean; - createdAt: number; -} - -export interface MediaNodeData { - id: string; - ip: string; - host: string; -} - -export interface Reaction { - id: string; - name: string; - peerId: string; - peerName: string; - position: `${number}%`; - timestamp: number; -} - -export interface PipeConsumerParams { - producerId: string; - kind: mediasoupTypes.MediaKind; - producerPaused: boolean; - rtpParameters: mediasoupTypes.RtpParameters; - sendTranportId: string; - roomId: string; - recvMediaNodeId: string; - sendMediaNodeId: string; - producerPeerId: string; - appData: mediasoupTypes.AppData; -} - -export interface TransportConnectionParams { - routerId: string; - transportId: string; - sendTransportId?: string; - recvTransportId?: string; - ip: string; - port: number; - srtpParameters?: mediasoupTypes.SrtpParameters; -} - -// WorkerData, RouterData, TransportData, ConsumerData, Producer From 989301c85a14805b2394c4a15f4dac64dc14cbdf Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sat, 16 Aug 2025 23:11:32 +0100 Subject: [PATCH 12/31] update type and helper file import path --- src/services/medianode.ts | 212 ++++++++++++++++++ src/services/peer.ts | 2 +- src/services/room.ts | 427 +++++++++++++++++++++++++++++++++++++ src/services/signalnode.ts | 9 + src/types/index.ts | 157 ++++++++++++++ 5 files changed, 806 insertions(+), 1 deletion(-) create mode 100644 src/services/medianode.ts create mode 100644 src/services/room.ts create mode 100644 src/services/signalnode.ts create mode 100644 src/types/index.ts diff --git a/src/services/medianode.ts b/src/services/medianode.ts new file mode 100644 index 0000000..950ce82 --- /dev/null +++ b/src/services/medianode.ts @@ -0,0 +1,212 @@ +import { EventEmitter } from 'events'; +import { types as mediasoupTypes } from 'mediasoup'; +import Room from './room'; +import { TransportConnectionParams } from '../types'; +import config from '../config'; + +type AppDataWithRouterId = mediasoupTypes.AppData & { routerId: string }; + +class MediaNode extends EventEmitter { + id: string; + roomId: string; + // map routerId to pipetransport + sendPipeTransports: Map< + string, + mediasoupTypes.PipeTransport + >; + recvRouter: mediasoupTypes.Router; + // map of remote sendTranportId to recvPipeTransport + recvPipeTransports: Map< + string, + mediasoupTypes.PipeTransport + >; + producers: Map; + consumers: Map; + + private static MediaNodes: Map = new Map(); + + constructor({ + id, + roomId, + sendPipeTransports, + recvRouter, + }: { + id: string; + roomId: string; + recvRouter: mediasoupTypes.Router; + sendPipeTransports: Map< + string, + mediasoupTypes.PipeTransport + >; + }) { + super(); + + this.id = id; + this.roomId = roomId; + + this.producers = new Map(); + this.consumers = new Map(); + this.recvRouter = recvRouter; + this.sendPipeTransports = sendPipeTransports; + this.recvPipeTransports = new Map(); + + // MediaNode.MediaNodes.set(id, this) + // consider increasing workerload systematically. + } + + close(): void { + this.producers.clear(); + this.consumers.clear(); + this.sendPipeTransports.clear(); + + // this.emit(SERVICE_EVENTS.close); + this.removeAllListeners(); + } + + static async create({ + room, + mediaNodeId, + }: { + room: Room; + mediaNodeId: string; + }): Promise { + try { + const sendRouters = Array.from(room.getRouters()); + const sendPipeTransports = await Promise.all( + sendRouters.map(router => room.createPipeTransport({ router })) + ); + + const sendPipeTansportsMap = new Map< + string, + mediasoupTypes.PipeTransport + >(); + for (const transport of sendPipeTransports) { + const routerId = transport.appData.routerId; + sendPipeTansportsMap.set(routerId, transport); + } + + const recvRouter = room.getLeastLoadedRouter(); + // const recvPipeTransport = await meeting.createPipeTransport({ router: recvRouter }); + + const mediaNode = new MediaNode({ + id: mediaNodeId, + roomId: room.roomId, + sendPipeTransports: sendPipeTansportsMap, + recvRouter, + }); + + room.addMediaNode(mediaNode); + return mediaNode; + } catch (error) { + console.error('create medianode failed', error); + throw error; + } + } + + static getMediaNdode = (nodeId: string): MediaNode | undefined => { + return MediaNode.MediaNodes.get(nodeId); + }; + + async connectPipeTransport({ + connectionParam, + transport, + }: { + connectionParam: TransportConnectionParams; + transport: mediasoupTypes.PipeTransport; + }): Promise { + try { + await transport.connect({ + ip: connectionParam.ip, + port: connectionParam.port, + srtpParameters: connectionParam.srtpParameters, + }); + } catch (error) { + console.error('connectPipeTransport failed', error); + } + } + + async createRecvPipeTransport(): Promise { + try { + const pipeTransport = await this.recvRouter.createPipeTransport({ + listenInfo: config.mediasoup.transportListenInfo, + enableSctp: true, + numSctpStreams: { OS: 1024, MIS: 1024 }, + enableRtx: false, + enableSrtp: false, + appData: { + routerId: this.recvRouter.id, + }, + }); + return pipeTransport; + } catch (error) { + console.error('createRecvPipeTransport failed', error); + throw error; + } + } + + getSendPipeTransportsConnectionParam(): TransportConnectionParams[] { + // get array of the connection params of send tranports + const connectionParams: TransportConnectionParams[] = []; + + this.sendPipeTransports.forEach((transport, routerId) => { + connectionParams.push({ + routerId, + transportId: transport.id, + sendTransportId: transport.id, + ip: transport.tuple.localIp, + port: transport.tuple.localPort, + srtpParameters: transport.srtpParameters, + }); + }); + + return connectionParams; + } + + getSendPipeTransport( + routerId: string + ): mediasoupTypes.PipeTransport | undefined { + return this.sendPipeTransports.get(routerId); + } + + getRecvPipeTransport( + remoteSendTranportId: string + ): mediasoupTypes.PipeTransport | undefined { + return this.recvPipeTransports.get(remoteSendTranportId); + } + + removeConsumer(id: string): void { + this.consumers.delete(id); + } + + addConsumer(consumer: mediasoupTypes.Consumer): void { + this.consumers.set(consumer.id, consumer); + consumer.observer.on('close', () => { + this.consumers.delete(consumer.id); + }); + } + getConsumer(id: string): mediasoupTypes.Consumer | undefined { + return this.consumers.get(id); + } + + // Producers methods + addProducer(producer: mediasoupTypes.Producer): void { + this.producers.set(producer.id, producer); + producer.observer.on('close', () => { + this.producers.delete(producer.id); + }); + } + + getProducers(): mediasoupTypes.Producer[] { + return Array.from(this.producers.values()); + } + + getProducer(id: string): mediasoupTypes.Producer | undefined { + return this.producers.get(id); + } + + removeProducer(id: string): void { + this.producers.delete(id); + } +} + +export default MediaNode; diff --git a/src/services/peer.ts b/src/services/peer.ts index 6c50878..102e20b 100644 --- a/src/services/peer.ts +++ b/src/services/peer.ts @@ -1,6 +1,6 @@ import EventEmitter from 'events'; import { types as mediasoupTypes } from 'mediasoup'; -import { PeerType, ProducerSource } from '../types/interfaces'; +import { PeerType, ProducerSource } from '../types'; import { mediaSoupServer } from '../servers/mediasoup-server'; class Peer extends EventEmitter { diff --git a/src/services/room.ts b/src/services/room.ts new file mode 100644 index 0000000..bcbead5 --- /dev/null +++ b/src/services/room.ts @@ -0,0 +1,427 @@ +import { types as mediasoupTypes } from 'mediasoup'; +import { EventEmitter } from 'events'; +import Peer from './peer'; +import config from '../config'; +import { mediaSoupServer } from '../servers/mediasoup-server'; +import { redisServer } from '../servers/redis-server'; +import { getKey } from '../lib/utils'; +import { ServiceEvents } from '../types/events'; +import { PipeConsumerParams } from '../types'; +import MediaNode from './medianode'; + +class Room extends EventEmitter { + roomId: string; + + activeSpeaker: { + peerId: string | null; + timestamp: number; + }; + closed: boolean; + // peers + + private peers: Map; + + //media nodes + mediaNodes: Map; + // mediasoup details + // workers: Map; + routers: Map; + audioLevelObservers: Map; + routerRtpCapabilities: mediasoupTypes.RtpCapabilities; + // all meets in the server + private static rooms = new Map(); + + constructor({ + roomId, + // workers, + routers, + audioLevelObservers, + }: { + roomId: string; + // workers: Map, + routers: Map; + audioLevelObservers: Map; + }) { + super(); + this.roomId = roomId; + + this.peers = new Map(); + // todo: checking if i should close medianode when meeting closes + this.mediaNodes = new Map(); + + this.closed = false; + this.routers = routers; + // this.workers = workers; + this.audioLevelObservers = audioLevelObservers; + this.routerRtpCapabilities = Array.from( + routers.values() + )[0].rtpCapabilities; + this.activeSpeaker = { + peerId: null, + timestamp: 0, + }; + + this.handleAudioLevelObserver(); + // this.handleEvents() + + Room.addRoom(this.roomId, this); + } + + close(): void { + // console.log('Closing room') + if (this.closed) return; + + for (const peer of this.getPeers()) { + peer.close(); + } + + // close routerss + for (const router of this.routers.values()) { + this.audioLevelObservers.delete(router.id); + router.close(); + } + + this.audioLevelObservers.clear(); + this.routers.clear(); + this.peers.clear(); + + Room.rooms.delete(this.roomId); + + // this.emit(SERVICE_EVENTS.close); + + this.removeAllListeners(); + + console.info(`Meeting - ${this.roomId} CLOSED`); + } + + static async create(roomId: string): Promise { + try { + const routers: Map = new Map(); + const audioLevelObservers: Map< + string, + mediasoupTypes.AudioLevelObserver + > = new Map(); + + for (const worker of mediaSoupServer.getWorkers()) { + const router = await worker.createRouter({ + mediaCodecs: config.mediasoup.routerMediaCodecs, + }); + routers.set(router.id, router); + + const audioLevelObserver = await router.createAudioLevelObserver({ + maxEntries: 1, + threshold: -80, + interval: 1800, + appData: { + peerId: null, + volume: -1000, + }, + }); + + audioLevelObservers.set(router.id, audioLevelObserver); + } + + const room = new Room({ roomId, routers, audioLevelObservers }); + + // publish mediaNodeJoinMeeting + // redisServer.publish + + return room; + } catch (error) { + console.error('create meeting failed', error); + throw error; + } + } + + static addRoom(roomId: string, room: Room): void { + Room.rooms.set(roomId, room); + } + + static getRoom(roomId: string): Room | undefined { + return Room.rooms.get(roomId); + } + + static removeRoom(roomId: string): void { + Room.rooms.delete(roomId); + } + + // peers + addPeer(peer: Peer): void { + this.peers.set(peer.id, peer); + // this.emit(SERVICE_EVENTS.peerAdded, peer); + this.handlePeerEvents(peer); + } + + getPeer(id: string): Peer | undefined { + return this.peers.get(id); + } + + getPeers(): Peer[] { + return Array.from(this.peers.values()); + } + + removePeer(id: string): void { + // const peer = this.peers.get(id); + this.peers.delete(id); + + // if (peer) this.emit(SERVICE_EVENTS.peerRemoved, peer); + } + + getRouters(): mediasoupTypes.Router[] { + return Array.from(this.routers.values()); + } + + async assignRouterToPeer(): Promise { + const router = this.getLeastLoadedRouter(); + if (router) { + await this.pipeProducersToRouter(router); + return router; + } + return null; + } + + getRoutersToPipeTo( + originRouter: mediasoupTypes.Router + ): mediasoupTypes.Router[] { + return Array.from(this.routers.values()).filter( + router => router.id !== originRouter.id + ); + } + + getLeastLoadedRouter(): mediasoupTypes.Router | null { + // the least loaded router, + // is the room router of the least loaded worker + + const leastLoadedWorker = mediaSoupServer.getLeastLoadedWorker(); + + if (!leastLoadedWorker) throw 'Least Loaded Worker not found'; + + for (const routerId of ( + leastLoadedWorker.appData.routers as Map + ).keys()) { + const router = this.routers.get(routerId); + if (router) { + return router; + } + } + + return null; + } + + private async pipeProducersToRouter( + router: mediasoupTypes.Router + ): Promise { + try { + const peersToPipe = Array.from(this.peers.values()).filter( + peer => peer.router.id !== router.id + ); + for (const peer of peersToPipe) { + const srcRouter = peer.router; + if (srcRouter) { + for (const producerId of peer.producers.keys()) { + if ( + ( + router.appData.producers as Map + ).has(producerId) + ) { + continue; + } + await srcRouter.pipeToRouter({ + producerId, + router, + }); + } + } + } + } catch (error) { + console.error('pipeProducersToRouter', error); + } + } + + private handleAudioLevelObserver(): void { + this.audioLevelObservers.forEach(audioLevelObserver => { + audioLevelObserver.on('volumes', volumes => { + const { producer, volume } = volumes[0]; + + audioLevelObserver.appData = { + ...audioLevelObserver.appData, + peerId: producer.appData.peerId, + volume: volume, + // speakerIds + }; + this.broadcastActiveSpeakerInfo(); + }); + + audioLevelObserver.on('silence', () => { + audioLevelObserver.appData = { + ...audioLevelObserver.appData, + peerId: null, + volume: -1000, + // speakerIds: [] + }; + + this.broadcastActiveSpeakerInfo(); + }); + }); + } + + private broadcastActiveSpeakerInfo = (): void => { + let peerId = null; + let maxVolume = -1000; + const speakerIds: string[] = []; + + this.audioLevelObservers.forEach(audioLevelObserver => { + const tmpPeerId = audioLevelObserver.appData.peerId as string; + const tmpVolume = audioLevelObserver.appData.volume as number; + if (tmpPeerId) { + if (tmpVolume > maxVolume) { + peerId = tmpPeerId; + maxVolume = tmpVolume; + } + speakerIds.push(tmpPeerId); + } + }); + + if (this.activeSpeaker.peerId === peerId) return; + // this ensures that the minimun gap between the previous and the current + // active speaker is 1 second + if (Date.now() > this.activeSpeaker.timestamp + 2000) { + this.activeSpeaker = { + peerId, + timestamp: Date.now(), + }; + + // store meeting active speaker peerid in db. + // todo may require a different position when optimising for multiple media servers] + redisServer.set( + getKey['roomActiveSpeakerPeerId'](this.roomId), + JSON.stringify(peerId) + ); + + // todo work on optimising this in future to send to once to a signal node and the signal node broadcast to all peers + // there is an edge case this implemenation is not working for. + // it is not broadcasting for peers in other servers. + for (const peer of this.peers.values()) { + if (peer.id === peerId) continue; + // Todo optimise + // peer.signalNode.sendMessage(SIGNALLING_EVENTS.activeSpeaker, { + // peerId: peer.id, + // peerType: peer.type, + // roomId: this.meetingId, + // volume: maxVolume, + // speakerIds, + // activeSpeakerPeerId: peerId, + // }); + } + } + }; + + private handlePeerEvents(peer: Peer): void { + peer.on(ServiceEvents.Close, () => { + if (!this.getPeer(peer.id)) return; + this.removePeer(peer.id); + }); + } + + async createPipeTransport({ + router, + }: { + router: mediasoupTypes.Router; + }): Promise { + const pipeTransport = await router.createPipeTransport({ + listenInfo: config.mediasoup.transportListenInfo, + enableSctp: true, + numSctpStreams: { OS: 1024, MIS: 1024 }, + enableRtx: false, + enableSrtp: false, + appData: { + routerId: router.id, + }, + }); + return pipeTransport; + } + + async createPipeConsumersForExistingProducers({ + consumingMediaNode, + }: { + consumingMediaNode: MediaNode; + }): Promise { + const peers = this.getPeers(); + for (const peer of peers) { + const peerProducers = peer.producers.values(); + for (const producer of peerProducers) { + this.createPipeConsumer({ + producer, + producerPeerId: peer.id, + consumingMediaNode, + }); + } + } + } + + async createPipeConsumer({ + producer, + producerPeerId, + consumingMediaNode, + }: { + producer: mediasoupTypes.Producer; + producerPeerId: string; + consumingMediaNode: MediaNode; + }): Promise { + try { + const router = this.getLeastLoadedRouter(); + const sendTranport = consumingMediaNode.getSendPipeTransport(router.id); + const pipeConsumer = await sendTranport.consume({ + producerId: producer.id, + }); + consumingMediaNode.addConsumer(pipeConsumer); + + const params: PipeConsumerParams = { + producerId: producer.id, + kind: pipeConsumer.kind, + producerPaused: pipeConsumer.producerPaused, + rtpParameters: pipeConsumer.rtpParameters, + sendTranportId: sendTranport.id, + recvMediaNodeId: consumingMediaNode.id, + sendMediaNodeId: 'config.env.mediaNodeId', + roomId: this.roomId, + producerPeerId, + appData: producer.appData, + }; + /** + * todo + * channel tras + const signal = SignalNode.getASignalNode(); + signal.sendMessage(SIGNALLING_EVENTS.newPipeConsumer, params); + + pipeConsumer.observer.on('close', () => { + signal.sendMessage(SIGNALLING_EVENTS.closePipeConsumer, params); + }); + + pipeConsumer.on('producerpause', () => { + signal.sendMessage(SIGNALLING_EVENTS.pausePipeConsumer, params); + }); + + pipeConsumer.on('producerresume', () => { + signal.sendMessage(SIGNALLING_EVENTS.resumePipeConsumer, params); + }); + console.log('createPipeConsumer'); + */ + } catch (error) { + console.error(`Pipe Consumer failed ${error}`); + } + } + + addMediaNode(mediaNode: MediaNode): void { + this.mediaNodes.set(mediaNode.id, mediaNode); + mediaNode.on(ServiceEvents.Close, () => { + this.mediaNodes.delete(mediaNode.id); + }); + } + + getMediaNodes(): MediaNode[] { + return Array.from(this.mediaNodes.values()); + } +} + +export default Room; diff --git a/src/services/signalnode.ts b/src/services/signalnode.ts new file mode 100644 index 0000000..862b5ca --- /dev/null +++ b/src/services/signalnode.ts @@ -0,0 +1,9 @@ +import EventEmitter from 'events'; + +class SignalNode extends EventEmitter { + constructor() { + super(); + } +} + +export default SignalNode; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..3306eda --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,157 @@ +import { types as mediasoupTypes } from 'mediasoup'; + +export type ProducerSource = 'mic' | 'camera' | 'screen' | 'screenAudio'; + +export type TransportKind = 'producer' | 'consumer'; +export type AckCallback = (res: { + status: 'success' | 'error'; + error?: Error | unknown | null; + response?: T; +}) => void; +export type PeerType = 'Recorder' | 'Participant'; + +export enum Role { + Moderator = 'Moderator', + Speaker = 'Speaker', + Participant = 'Participant', +} + +export enum Tag { + Host = 'Host', + Cohost = 'Co-host', + Moderator = 'Moderator', + Speaker = 'Speaker', + Pinned = 'Pinned', + Participant = 'Participant', +} + +export enum HTTPSTATUS { + OK = 200, + CREATED = 201, + BAD_REQUEST = 400, + UNAUTHORISED = 401, + FORBIDDEN = 403, + NOT_FOUND = 404, + CONFLICT = 409, + INTERNAL_SERVER_ERROR = 500, +} + +export interface HandState { + raised: boolean; + timestamp?: number; +} + +export interface MessageData { + event: string; + args: { [key: string]: unknown }; +} + +export interface RoomData { + id: string; + title: string; + roomId: string; + description?: string; + host: { + id: string; + name: string; + }; + coHostEmails: string[]; + guestEmails: string[]; + allowWaiting?: boolean; +} + +export interface RoomInstanceData { + roomId: string; + hostId: string; + coHostEmails: string[]; + started: number; + maxDuration: number; + maxPeers: number; + allowRecording: boolean; + allowWaiting: boolean; + activeSpeakerPeerId?: string | null; + recording: boolean; + timeLeft?: number; + isFull?: boolean; +} + +export interface PeerData { + id: string; + userId?: string; + name: string; + email?: string; + photo?: string; + color?: string; + isMobileDevice?: boolean; + jobTitle?: string; + isRejoining?: boolean; + isRecorder?: boolean; + hand?: HandState; + roles?: Role[]; + tag?: Tag; + pinned?: boolean; + online?: boolean; + joined?: number; + reconnecting?: boolean; +} + +export interface AttendeeData { + id: string; + name: string; + userId?: string; + photo?: string; + color?: string; + email?: string; + info?: string; + joined: number; +} + +export interface ChatData { + id: string; + text: string; + sender: PeerData; + receiver: PeerData; + isFile?: boolean; + isPinned?: boolean; + createdAt: number; +} + +export interface MediaNodeData { + id: string; + ip: string; + address: string; +} + +export interface Reaction { + id: string; + name: string; + peerId: string; + peerName: string; + position: `${number}%`; + timestamp: number; +} + +export interface PipeConsumerParams { + producerId: string; + kind: mediasoupTypes.MediaKind; + producerPaused: boolean; + rtpParameters: mediasoupTypes.RtpParameters; + sendTranportId: string; + roomId: string; + recvMediaNodeId: string; + sendMediaNodeId: string; + producerPeerId: string; + appData: mediasoupTypes.AppData; +} + +export interface TransportConnectionParams { + routerId: string; + transportId: string; + sendTransportId?: string; + recvTransportId?: string; + ip: string; + port: number; + srtpParameters?: mediasoupTypes.SrtpParameters; +} + +// WorkerData, RouterData, TransportData, ConsumerData, Producer From 6a04695d8ad06f8c7af2163bf87e75da514b3f6f Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 14:59:34 +0100 Subject: [PATCH 13/31] regenerate proto type files --- proto-gen.sh | 2 +- src/config/index.ts | 1 + src/lib/utils.ts | 3 ++- src/protos/gen/media-signaling.ts | 19 +++++++++++++++ .../mediaSignalingPackage/MediaSignaling.ts | 23 +++++++++++++++++++ .../SendMessageRequest.ts | 4 ++-- .../SendMessageResponse.ts | 4 ++-- src/protos/media-signaling.proto | 11 ++++++--- src/protos/media-signaling.ts | 19 --------------- .../media_signaling_package/MediaSignaling.ts | 23 ------------------- src/types/index.ts | 1 + 11 files changed, 59 insertions(+), 51 deletions(-) create mode 100644 src/protos/gen/media-signaling.ts create mode 100644 src/protos/gen/mediaSignalingPackage/MediaSignaling.ts rename src/protos/{media_signaling_package => gen/mediaSignalingPackage}/SendMessageRequest.ts (70%) rename src/protos/{media_signaling_package => gen/mediaSignalingPackage}/SendMessageResponse.ts (70%) delete mode 100644 src/protos/media-signaling.ts delete mode 100644 src/protos/media_signaling_package/MediaSignaling.ts diff --git a/proto-gen.sh b/proto-gen.sh index 8c4ef9d..7824bf2 100755 --- a/proto-gen.sh +++ b/proto-gen.sh @@ -1,3 +1,3 @@ #!/bin/bash -npx proto-loader-gen-types --grpcLib=@grpc/grpc-js --outDir=src/protos/ src/protos/*.proto +npx proto-loader-gen-types --grpcLib=@grpc/grpc-js --outDir=src/protos/gen/ src/protos/*.proto diff --git a/src/config/index.ts b/src/config/index.ts index 3471e55..664a12a 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -28,6 +28,7 @@ const config = { cert: fs.readFileSync(certFile, 'utf8'), }, port: process.env.PORT || 4000, + grpcPort: process.env.GRPC_PORT || 50052, cpus: Object.keys(os.cpus()).length, apiServerUrl: process.env.API_SERVER_URL, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 6d26939..dc8d50f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -22,8 +22,9 @@ export const registerMediaNode = async (): Promise => { const ip = await publicIpv4(); const medianodeData: MediaNodeData = { id: ip || config.serverId, - ip, + ip: '0.0.0.0', address: `${config.port}`, + grpcPort: `${config.grpcPort}`, }; await redisServer.sAdd( getRedisKey['medianodesRunning'](), diff --git a/src/protos/gen/media-signaling.ts b/src/protos/gen/media-signaling.ts new file mode 100644 index 0000000..f317b26 --- /dev/null +++ b/src/protos/gen/media-signaling.ts @@ -0,0 +1,19 @@ +import type * as grpc from '@grpc/grpc-js'; +import type { MessageTypeDefinition } from '@grpc/proto-loader'; + +import type { MediaSignalingClient as _mediaSignalingPackage_MediaSignalingClient, MediaSignalingDefinition as _mediaSignalingPackage_MediaSignalingDefinition } from './mediaSignalingPackage/MediaSignaling'; +import type { SendMessageRequest as _mediaSignalingPackage_SendMessageRequest, SendMessageRequest__Output as _mediaSignalingPackage_SendMessageRequest__Output } from './mediaSignalingPackage/SendMessageRequest'; +import type { SendMessageResponse as _mediaSignalingPackage_SendMessageResponse, SendMessageResponse__Output as _mediaSignalingPackage_SendMessageResponse__Output } from './mediaSignalingPackage/SendMessageResponse'; + +type SubtypeConstructor any, Subtype> = { + new(...args: ConstructorParameters): Subtype; +}; + +export interface ProtoGrpcType { + mediaSignalingPackage: { + MediaSignaling: SubtypeConstructor & { service: _mediaSignalingPackage_MediaSignalingDefinition } + SendMessageRequest: MessageTypeDefinition<_mediaSignalingPackage_SendMessageRequest, _mediaSignalingPackage_SendMessageRequest__Output> + SendMessageResponse: MessageTypeDefinition<_mediaSignalingPackage_SendMessageResponse, _mediaSignalingPackage_SendMessageResponse__Output> + } +} + diff --git a/src/protos/gen/mediaSignalingPackage/MediaSignaling.ts b/src/protos/gen/mediaSignalingPackage/MediaSignaling.ts new file mode 100644 index 0000000..ae0fe36 --- /dev/null +++ b/src/protos/gen/mediaSignalingPackage/MediaSignaling.ts @@ -0,0 +1,23 @@ +// Original file: src/protos/media-signaling.proto + +import type * as grpc from '@grpc/grpc-js' +import type { MethodDefinition } from '@grpc/proto-loader' +import type { SendMessageRequest as _mediaSignalingPackage_SendMessageRequest, SendMessageRequest__Output as _mediaSignalingPackage_SendMessageRequest__Output } from '../mediaSignalingPackage/SendMessageRequest'; +import type { SendMessageResponse as _mediaSignalingPackage_SendMessageResponse, SendMessageResponse__Output as _mediaSignalingPackage_SendMessageResponse__Output } from '../mediaSignalingPackage/SendMessageResponse'; + +export interface MediaSignalingClient extends grpc.Client { + SendMessage(metadata: grpc.Metadata, options?: grpc.CallOptions): grpc.ClientDuplexStream<_mediaSignalingPackage_SendMessageRequest, _mediaSignalingPackage_SendMessageResponse__Output>; + SendMessage(options?: grpc.CallOptions): grpc.ClientDuplexStream<_mediaSignalingPackage_SendMessageRequest, _mediaSignalingPackage_SendMessageResponse__Output>; + sendMessage(metadata: grpc.Metadata, options?: grpc.CallOptions): grpc.ClientDuplexStream<_mediaSignalingPackage_SendMessageRequest, _mediaSignalingPackage_SendMessageResponse__Output>; + sendMessage(options?: grpc.CallOptions): grpc.ClientDuplexStream<_mediaSignalingPackage_SendMessageRequest, _mediaSignalingPackage_SendMessageResponse__Output>; + +} + +export interface MediaSignalingHandlers extends grpc.UntypedServiceImplementation { + SendMessage: grpc.handleBidiStreamingCall<_mediaSignalingPackage_SendMessageRequest__Output, _mediaSignalingPackage_SendMessageResponse>; + +} + +export interface MediaSignalingDefinition extends grpc.ServiceDefinition { + SendMessage: MethodDefinition<_mediaSignalingPackage_SendMessageRequest, _mediaSignalingPackage_SendMessageResponse, _mediaSignalingPackage_SendMessageRequest__Output, _mediaSignalingPackage_SendMessageResponse__Output> +} diff --git a/src/protos/media_signaling_package/SendMessageRequest.ts b/src/protos/gen/mediaSignalingPackage/SendMessageRequest.ts similarity index 70% rename from src/protos/media_signaling_package/SendMessageRequest.ts rename to src/protos/gen/mediaSignalingPackage/SendMessageRequest.ts index d533e6d..4ea3875 100644 --- a/src/protos/media_signaling_package/SendMessageRequest.ts +++ b/src/protos/gen/mediaSignalingPackage/SendMessageRequest.ts @@ -3,10 +3,10 @@ export interface SendMessageRequest { 'type'?: (string); - 'args'?: ({[key: string]: string}); + 'args'?: (string); } export interface SendMessageRequest__Output { 'type'?: (string); - 'args'?: ({[key: string]: string}); + 'args'?: (string); } diff --git a/src/protos/media_signaling_package/SendMessageResponse.ts b/src/protos/gen/mediaSignalingPackage/SendMessageResponse.ts similarity index 70% rename from src/protos/media_signaling_package/SendMessageResponse.ts rename to src/protos/gen/mediaSignalingPackage/SendMessageResponse.ts index 3502749..423bcf5 100644 --- a/src/protos/media_signaling_package/SendMessageResponse.ts +++ b/src/protos/gen/mediaSignalingPackage/SendMessageResponse.ts @@ -3,10 +3,10 @@ export interface SendMessageResponse { 'type'?: (string); - 'args'?: ({[key: string]: string}); + 'args'?: (string); } export interface SendMessageResponse__Output { 'type'?: (string); - 'args'?: ({[key: string]: string}); + 'args'?: (string); } diff --git a/src/protos/media-signaling.proto b/src/protos/media-signaling.proto index 83160de..80bac0a 100644 --- a/src/protos/media-signaling.proto +++ b/src/protos/media-signaling.proto @@ -1,16 +1,21 @@ syntax = "proto3"; -package media_signaling_package; +package mediaSignalingPackage; +// Service definition for bidirectional communication service MediaSignaling { + // Send Message rpc SendMessage(stream SendMessageRequest) returns (stream SendMessageResponse) {}; } + + +// Health check messages message SendMessageRequest { string type = 1; - map args = 2; + string args = 2; } message SendMessageResponse { string type = 1; - map args = 2; + string args = 2; } diff --git a/src/protos/media-signaling.ts b/src/protos/media-signaling.ts deleted file mode 100644 index b4f09b7..0000000 --- a/src/protos/media-signaling.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type * as grpc from '@grpc/grpc-js'; -import type { MessageTypeDefinition } from '@grpc/proto-loader'; - -import type { MediaSignalingClient as _media_signaling_package_MediaSignalingClient, MediaSignalingDefinition as _media_signaling_package_MediaSignalingDefinition } from './media_signaling_package/MediaSignaling'; -import type { SendMessageRequest as _media_signaling_package_SendMessageRequest, SendMessageRequest__Output as _media_signaling_package_SendMessageRequest__Output } from './media_signaling_package/SendMessageRequest'; -import type { SendMessageResponse as _media_signaling_package_SendMessageResponse, SendMessageResponse__Output as _media_signaling_package_SendMessageResponse__Output } from './media_signaling_package/SendMessageResponse'; - -type SubtypeConstructor any, Subtype> = { - new(...args: ConstructorParameters): Subtype; -}; - -export interface ProtoGrpcType { - media_signaling_package: { - MediaSignaling: SubtypeConstructor & { service: _media_signaling_package_MediaSignalingDefinition } - SendMessageRequest: MessageTypeDefinition<_media_signaling_package_SendMessageRequest, _media_signaling_package_SendMessageRequest__Output> - SendMessageResponse: MessageTypeDefinition<_media_signaling_package_SendMessageResponse, _media_signaling_package_SendMessageResponse__Output> - } -} - diff --git a/src/protos/media_signaling_package/MediaSignaling.ts b/src/protos/media_signaling_package/MediaSignaling.ts deleted file mode 100644 index 05b30dc..0000000 --- a/src/protos/media_signaling_package/MediaSignaling.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Original file: src/protos/media-signaling.proto - -import type * as grpc from '@grpc/grpc-js' -import type { MethodDefinition } from '@grpc/proto-loader' -import type { SendMessageRequest as _media_signaling_package_SendMessageRequest, SendMessageRequest__Output as _media_signaling_package_SendMessageRequest__Output } from '../media_signaling_package/SendMessageRequest'; -import type { SendMessageResponse as _media_signaling_package_SendMessageResponse, SendMessageResponse__Output as _media_signaling_package_SendMessageResponse__Output } from '../media_signaling_package/SendMessageResponse'; - -export interface MediaSignalingClient extends grpc.Client { - SendMessage(metadata: grpc.Metadata, options?: grpc.CallOptions): grpc.ClientDuplexStream<_media_signaling_package_SendMessageRequest, _media_signaling_package_SendMessageResponse__Output>; - SendMessage(options?: grpc.CallOptions): grpc.ClientDuplexStream<_media_signaling_package_SendMessageRequest, _media_signaling_package_SendMessageResponse__Output>; - sendMessage(metadata: grpc.Metadata, options?: grpc.CallOptions): grpc.ClientDuplexStream<_media_signaling_package_SendMessageRequest, _media_signaling_package_SendMessageResponse__Output>; - sendMessage(options?: grpc.CallOptions): grpc.ClientDuplexStream<_media_signaling_package_SendMessageRequest, _media_signaling_package_SendMessageResponse__Output>; - -} - -export interface MediaSignalingHandlers extends grpc.UntypedServiceImplementation { - SendMessage: grpc.handleBidiStreamingCall<_media_signaling_package_SendMessageRequest__Output, _media_signaling_package_SendMessageResponse>; - -} - -export interface MediaSignalingDefinition extends grpc.ServiceDefinition { - SendMessage: MethodDefinition<_media_signaling_package_SendMessageRequest, _media_signaling_package_SendMessageResponse, _media_signaling_package_SendMessageRequest__Output, _media_signaling_package_SendMessageResponse__Output> -} diff --git a/src/types/index.ts b/src/types/index.ts index 3306eda..cc5f93c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -120,6 +120,7 @@ export interface MediaNodeData { id: string; ip: string; address: string; + grpcPort: string; } export interface Reaction { From 0de975d9f24a30fa6c99f65b167b28171ab6cf1f Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 14:59:52 +0100 Subject: [PATCH 14/31] optimise grpc server --- src/servers/grpc-server.ts | 599 +++++++++++++++++++++++++++++++++++-- 1 file changed, 566 insertions(+), 33 deletions(-) diff --git a/src/servers/grpc-server.ts b/src/servers/grpc-server.ts index f7c692b..5c1c251 100644 --- a/src/servers/grpc-server.ts +++ b/src/servers/grpc-server.ts @@ -1,76 +1,609 @@ import * as grpc from '@grpc/grpc-js'; import * as protoLoader from '@grpc/proto-loader'; import path from 'path'; -import { ProtoGrpcType } from '../protos/media-signaling'; -import { MediaSignalingHandlers } from '../protos/media_signaling_package/MediaSignaling'; +import { EventEmitter } from 'events'; -class GrpcServer { +import config from '../config'; +import { SendMessageRequest } from '../protos/gen/mediaSignalingPackage/SendMessageRequest'; +import { SendMessageResponse } from '../protos/gen/mediaSignalingPackage/SendMessageResponse'; +import { MediaSignalingHandlers } from '../protos/gen/mediaSignalingPackage/MediaSignaling'; +import { ProtoGrpcType } from '../protos/gen/media-signaling'; + +interface ClientConnection { + id: string; + nodeId?: string; + call: grpc.ServerDuplexStream; + metadata: grpc.Metadata; + connectedAt: Date; + lastActivity: Date; + isActive: boolean; + heartbeatInterval?: NodeJS.Timeout; +} + +interface ServerStats { + totalConnections: number; + activeConnections: number; + totalMessagesReceived: number; + totalMessagesSent: number; + uptime: number; + startTime: Date; +} + +class GrpcServer extends EventEmitter { private static instance: GrpcServer | null = null; private server: grpc.Server; - private connections: Map; + private connections: Map; + private isRunning: boolean; + private startTime: Date; + private stats: ServerStats; + private cleanupInterval: NodeJS.Timeout | null; private constructor() { - this.server = new grpc.Server(); + super(); + this.server = new grpc.Server({ + 'grpc.keepalive_time_ms': 10000, + 'grpc.keepalive_timeout_ms': 5000, + 'grpc.keepalive_permit_without_calls': 1, + 'grpc.http2.max_pings_without_data': 0, + 'grpc.http2.min_time_between_pings_ms': 10000, + 'grpc.http2.min_ping_interval_without_data_ms': 300000, + 'grpc.max_connection_idle_ms': 300000, + 'grpc.max_connection_age_ms': 600000, + 'grpc.max_connection_age_grace_ms': 30000, + }); + this.connections = new Map(); + this.isRunning = false; + this.startTime = new Date(); + this.cleanupInterval = null; + + this.stats = { + totalConnections: 0, + activeConnections: 0, + totalMessagesReceived: 0, + totalMessagesSent: 0, + uptime: 0, + startTime: this.startTime, + }; + this.setup(); + this.setupCleanupInterval(); + + // Graceful shutdown handling + process.on('SIGINT', () => this.gracefulShutdown('SIGINT')); + process.on('SIGTERM', () => this.gracefulShutdown('SIGTERM')); } static getInstance(): GrpcServer { if (!GrpcServer.instance) { GrpcServer.instance = new GrpcServer(); } - return GrpcServer.instance; } - async start(port: number = 50052): Promise { + async start(): Promise { + if (this.isRunning) { + console.log('gRPC server is already running'); + return; + } + try { - this.server.bindAsync( - `0.0.0.0:${port}`, - grpc.ServerCredentials.createInsecure(), - (err, port) => { - if (err) { - console.error(err); - return; + await new Promise((resolve, reject) => { + this.server.bindAsync( + `0.0.0.0:${config.grpcPort}`, + grpc.ServerCredentials.createInsecure(), + (err, port) => { + if (err) { + console.error('Failed to bind gRPC server:', err); + reject(err); + return; + } + + this.isRunning = true; + this.startTime = new Date(); + this.stats.startTime = this.startTime; + + console.log( + `โœ… gRPC Media Signaling Server started on port ${port}` + ); + console.log( + `๐Ÿ“ก Server ready to accept connections at 0.0.0.0:${port}` + ); + + this.emit('serverStarted', { port }); + resolve(); } - console.log(`Your server as started on port ${port}`); - } - ); + ); + }); } catch (error) { - console.error(error); + console.error('Error starting gRPC server:', error); throw error; } } + async stop(): Promise { + if (!this.isRunning) { + console.log('gRPC server is not running'); + return; + } + + console.log('๐Ÿ›‘ Stopping gRPC server...'); + + // Close all active connections first + await this.closeAllConnections(); + + // Clear cleanup interval + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + return new Promise(resolve => { + this.server.tryShutdown(error => { + if (error) { + console.error('Error during server shutdown:', error); + // Force shutdown if graceful shutdown fails + this.server.forceShutdown(); + } + + this.isRunning = false; + console.log('โœ… gRPC server stopped successfully'); + this.emit('serverStopped'); + resolve(); + }); + }); + } + private setup(): void { const PROTO_FILE = path.resolve( __dirname, '../protos/media-signaling.proto' ); - const packageDefinition = protoLoader.loadSync(PROTO_FILE); const protoDescriptor = grpc.loadPackageDefinition( packageDefinition ) as unknown as ProtoGrpcType; - const mediaSignaling = - protoDescriptor.media_signaling_package.MediaSignaling; + const mediaSignaling = protoDescriptor.mediaSignalingPackage.MediaSignaling; this.server.addService(mediaSignaling.service, { - SendMessage: call => { - call.on('data', chunk => { - console.log('Message from client'); - console.log(chunk); - }); + SendMessage: this.handleSendMessage.bind(this), + } as MediaSignalingHandlers); + } + + private handleSendMessage( + call: grpc.ServerDuplexStream + ): void { + const clientId = this.extractClientId(call.metadata); + const nodeId = this.extractNodeId(call.metadata); + + const connection: ClientConnection = { + id: clientId, + nodeId, + call, + metadata: call.metadata, + connectedAt: new Date(), + lastActivity: new Date(), + isActive: true, + }; + + // Store connection + this.connections.set(clientId, connection); + this.stats.totalConnections++; + this.stats.activeConnections++; + + console.log( + `๐Ÿ“ฑ New client connected: ${clientId}${nodeId ? ` (Node: ${nodeId})` : ''}` + ); + console.log(`๐Ÿ“Š Active connections: ${this.stats.activeConnections}`); + + this.emit('clientConnected', { clientId, nodeId, connection }); + + // Setup heartbeat for this connection + this.setupHeartbeat(connection); + + // Handle incoming messages + call.on('data', (message: SendMessageRequest) => { + this.handleClientMessage(connection, message); + }); + + // Handle client disconnection + call.on('end', () => { + console.log(`๐Ÿ“ค Client ${clientId} ended the connection`); + this.handleClientDisconnection(clientId, 'client_ended'); + }); + + // Handle errors + call.on('error', (error: Error) => { + console.error(`โŒ Error with client ${clientId}:`, error.message); + this.handleClientDisconnection(clientId, 'error', error); + }); + + // Handle call cancellation + call.on('cancelled', () => { + console.log(`๐Ÿšซ Client ${clientId} cancelled the connection`); + this.handleClientDisconnection(clientId, 'cancelled'); + }); + + // Send initial connection confirmation + this.sendToClient(clientId, { + type: 'connection_confirmed', + args: JSON.stringify({ + status: 'success', + clientId, + serverTime: Date.now().toString(), + message: 'Successfully connected to Media Signaling Server', + }), + }); + } + + private handleClientMessage( + connection: ClientConnection, + message: SendMessageRequest + ): void { + connection.lastActivity = new Date(); + this.stats.totalMessagesReceived++; + + console.log( + `๐Ÿ“จ Message from client ${connection.id} (${message.type}):`, + message.args + ); + + this.emit('messageReceived', { + clientId: connection.id, + nodeId: connection.nodeId, + message, + connection, + }); + + // Handle specific message types + switch (message.type) { + case 'connect': + this.handleConnectMessage(connection, message); + break; + + case 'disconnect': + this.handleDisconnectMessage(connection, message); + break; + + case 'ping': + this.handlePingMessage(connection, message); + break; + + case 'media_request': + this.handleMediaRequest(connection, message); + break; + + default: + console.log(`๐Ÿ”„ Relaying message type: ${message.type}`); + this.relayMessageToNodes(connection, message); + break; + } + } + + private handleConnectMessage( + connection: ClientConnection, + message: SendMessageRequest + ): void { + console.log(`โœ… Client ${connection.id} sent connect confirmation`); + + // Update connection info if nodeId is provided + console.log(message.args); + + this.sendToClient(connection.id, { + type: 'connect_ack', + args: JSON.stringify({ + status: 'acknowledged', + serverTime: Date.now(), + activeConnections: this.stats.activeConnections, + }), + }); + } + + private handleDisconnectMessage( + connection: ClientConnection, + message: SendMessageRequest + ): void { + console.log( + `๐Ÿ‘‹ Client ${connection.id} requested disconnection:`, + message.args + ); + + this.sendToClient(connection.id, { + type: 'disconnect_ack', + args: JSON.stringify({ + status: 'acknowledged', + message: 'Disconnection acknowledged', + }), + }); + + // Schedule connection cleanup + setTimeout(() => { + this.handleClientDisconnection(connection.id, 'client_requested'); + }, 1000); + } + + private handlePingMessage( + connection: ClientConnection, + message: SendMessageRequest + ): void { + // Respond to ping with pong + this.sendToClient(connection.id, { + type: 'pong', + args: JSON.stringify({ + timestamp: (message.args || Date.now()).toString(), + serverTime: Date.now().toString(), + }), + }); + } + + private handleMediaRequest( + connection: ClientConnection, + message: SendMessageRequest + ): void { + console.log(`๐ŸŽฌ Media request from ${connection.id}:`, message.args); + + // Process media request and respond + this.sendToClient(connection.id, { + type: 'media_response', + args: JSON.stringify({ + status: 'processing', + requestId: message.args || 'unknown', + message: 'Media request received and processing', + }), + }); + + // Emit event for external handling + this.emit('mediaRequest', { + clientId: connection.id, + nodeId: connection.nodeId, + request: message.args, + connection, + }); + } - call.write({ - type: 'confirm connection', - args: { - status: 'success', - }, + private relayMessageToNodes( + sender: ClientConnection, + message: SendMessageRequest + ): void { + // Relay message to other connected nodes (excluding sender) + let relayCount = 0; + + this.connections.forEach((connection, clientId) => { + if (clientId !== sender.id && connection.isActive && connection.nodeId) { + this.sendToClient(clientId, { + type: 'relay', + args: JSON.stringify({ + originalSender: sender.nodeId || sender.id, + originalMessage: message, + relayedAt: Date.now(), + }), }); - }, - } as MediaSignalingHandlers); + relayCount++; + } + }); + + console.log(`๐Ÿ”„ Relayed message from ${sender.id} to ${relayCount} nodes`); + } + + private setupHeartbeat(connection: ClientConnection): void { + connection.heartbeatInterval = setInterval(() => { + if (!connection.isActive) { + return; + } + + // Check if connection is stale (no activity for 60 seconds) + const timeSinceLastActivity = + Date.now() - connection.lastActivity.getTime(); + if (timeSinceLastActivity > 60000) { + console.log(`๐Ÿ’” Connection ${connection.id} is stale, removing...`); + this.handleClientDisconnection(connection.id, 'stale_connection'); + return; + } + + // Send heartbeat + this.sendToClient(connection.id, { + type: 'heartbeat', + args: JSON.stringify({ + serverTime: Date.now(), + uptime: Date.now() - this.stats.startTime.getTime(), + }), + }); + }, 30000); // Send heartbeat every 30 seconds + } + + private handleClientDisconnection( + clientId: string, + reason: string, + error?: Error + ): void { + const connection = this.connections.get(clientId); + if (!connection) return; + + console.log(`๐Ÿ”Œ Client ${clientId} disconnected (${reason})`); + + // Clear heartbeat + if (connection.heartbeatInterval) { + clearInterval(connection.heartbeatInterval); + } + + // Mark as inactive + connection.isActive = false; + + // Remove from connections + this.connections.delete(clientId); + this.stats.activeConnections = Math.max( + 0, + this.stats.activeConnections - 1 + ); + + console.log(`๐Ÿ“Š Active connections: ${this.stats.activeConnections}`); + + this.emit('clientDisconnected', { + clientId, + nodeId: connection.nodeId, + reason, + error, + connection, + }); + } + + private sendToClient( + clientId: string, + message: SendMessageResponse + ): boolean { + const connection = this.connections.get(clientId); + if (!connection || !connection.isActive) { + console.warn( + `โš ๏ธ Cannot send message to client ${clientId}: connection not found or inactive` + ); + return false; + } + + try { + connection.call.write(message); + this.stats.totalMessagesSent++; + connection.lastActivity = new Date(); + return true; + } catch (error) { + console.error(`โŒ Error sending message to client ${clientId}:`, error); + this.handleClientDisconnection(clientId, 'send_error', error as Error); + return false; + } + } + + private extractClientId(metadata: grpc.Metadata): string { + const clientId = metadata.get('clientId')?.[0]; + return typeof clientId === 'string' ? clientId : crypto.randomUUID(); + } + + private extractNodeId(metadata: grpc.Metadata): string | undefined { + const nodeId = metadata.get('nodeId')?.[0]; + return typeof nodeId === 'string' ? nodeId : undefined; + } + + private setupCleanupInterval(): void { + this.cleanupInterval = setInterval(() => { + this.performCleanup(); + }, 60000); // Run cleanup every minute + } + + private performCleanup(): void { + const now = Date.now(); + let cleanedUp = 0; + + this.connections.forEach((connection, clientId) => { + const timeSinceLastActivity = now - connection.lastActivity.getTime(); + + // Remove connections inactive for more than 5 minutes + if (timeSinceLastActivity > 300000) { + console.log(`๐Ÿงน Cleaning up stale connection: ${clientId}`); + this.handleClientDisconnection(clientId, 'cleanup_stale'); + cleanedUp++; + } + }); + + if (cleanedUp > 0) { + console.log(`๐Ÿงน Cleaned up ${cleanedUp} stale connections`); + } + + // Update uptime + this.stats.uptime = now - this.stats.startTime.getTime(); + } + + private async closeAllConnections(): Promise { + console.log(`๐Ÿ”Œ Closing ${this.connections.size} active connections...`); + + const closePromises: Promise[] = []; + + this.connections.forEach((connection, clientId) => { + closePromises.push( + new Promise(resolve => { + // Send shutdown notification + this.sendToClient(clientId, { + type: 'server_shutdown', + args: JSON.stringify({ + message: 'Server is shutting down', + timestamp: Date.now().toString(), + }), + }); + + // Clean up connection + setTimeout(() => { + this.handleClientDisconnection(clientId, 'server_shutdown'); + resolve(); + }, 1000); + }) + ); + }); + + await Promise.all(closePromises); + console.log('โœ… All connections closed'); + } + + private async gracefulShutdown(signal: string): Promise { + console.log(`\n๐Ÿ›‘ Received ${signal}, starting graceful shutdown...`); + + try { + await this.stop(); + console.log('โœ… Graceful shutdown completed'); + process.exit(0); + } catch (error) { + console.error('โŒ Error during graceful shutdown:', error); + process.exit(1); + } + } + + // Public API methods + public getStats(): ServerStats { + return { + ...this.stats, + uptime: Date.now() - this.stats.startTime.getTime(), + activeConnections: this.connections.size, + }; + } + + public getConnections(): ClientConnection[] { + return Array.from(this.connections.values()); + } + + public getConnection(clientId: string): ClientConnection | undefined { + return this.connections.get(clientId); + } + + public broadcastMessage( + message: SendMessageResponse, + excludeClient?: string + ): number { + let sentCount = 0; + + this.connections.forEach((connection, clientId) => { + if (clientId !== excludeClient && connection.isActive) { + if (this.sendToClient(clientId, message)) { + sentCount++; + } + } + }); + + console.log(`๐Ÿ“ข Broadcasted message to ${sentCount} clients`); + return sentCount; + } + + public sendToNode(nodeId: string, message: SendMessageResponse): boolean { + const connection = Array.from(this.connections.values()).find( + conn => conn.nodeId === nodeId && conn.isActive + ); + + if (!connection) { + console.warn(`โš ๏ธ Node ${nodeId} not found or inactive`); + return false; + } + + return this.sendToClient(connection.id, message); + } + + public checkIsRunning(): boolean { + return this.isRunning; } } From 83031ea9356cf1fa3d639de95e4e6d17b8c63f38 Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 20:12:51 +0100 Subject: [PATCH 15/31] update proto configuration --- src/protos/gen/media-signaling.ts | 8 ++++---- .../gen/mediaSignalingPackage/MediaSignaling.ts | 16 ++++++++-------- .../gen/mediaSignalingPackage/MessageRequest.ts | 12 ++++++++++++ .../gen/mediaSignalingPackage/MessageResponse.ts | 12 ++++++++++++ .../mediaSignalingPackage/SendMessageRequest.ts | 12 ------------ .../mediaSignalingPackage/SendMessageResponse.ts | 12 ------------ src/protos/media-signaling.proto | 16 ++++++---------- 7 files changed, 42 insertions(+), 46 deletions(-) create mode 100644 src/protos/gen/mediaSignalingPackage/MessageRequest.ts create mode 100644 src/protos/gen/mediaSignalingPackage/MessageResponse.ts delete mode 100644 src/protos/gen/mediaSignalingPackage/SendMessageRequest.ts delete mode 100644 src/protos/gen/mediaSignalingPackage/SendMessageResponse.ts diff --git a/src/protos/gen/media-signaling.ts b/src/protos/gen/media-signaling.ts index f317b26..412467e 100644 --- a/src/protos/gen/media-signaling.ts +++ b/src/protos/gen/media-signaling.ts @@ -2,8 +2,8 @@ import type * as grpc from '@grpc/grpc-js'; import type { MessageTypeDefinition } from '@grpc/proto-loader'; import type { MediaSignalingClient as _mediaSignalingPackage_MediaSignalingClient, MediaSignalingDefinition as _mediaSignalingPackage_MediaSignalingDefinition } from './mediaSignalingPackage/MediaSignaling'; -import type { SendMessageRequest as _mediaSignalingPackage_SendMessageRequest, SendMessageRequest__Output as _mediaSignalingPackage_SendMessageRequest__Output } from './mediaSignalingPackage/SendMessageRequest'; -import type { SendMessageResponse as _mediaSignalingPackage_SendMessageResponse, SendMessageResponse__Output as _mediaSignalingPackage_SendMessageResponse__Output } from './mediaSignalingPackage/SendMessageResponse'; +import type { MessageRequest as _mediaSignalingPackage_MessageRequest, MessageRequest__Output as _mediaSignalingPackage_MessageRequest__Output } from './mediaSignalingPackage/MessageRequest'; +import type { MessageResponse as _mediaSignalingPackage_MessageResponse, MessageResponse__Output as _mediaSignalingPackage_MessageResponse__Output } from './mediaSignalingPackage/MessageResponse'; type SubtypeConstructor any, Subtype> = { new(...args: ConstructorParameters): Subtype; @@ -12,8 +12,8 @@ type SubtypeConstructor any, Subtype> export interface ProtoGrpcType { mediaSignalingPackage: { MediaSignaling: SubtypeConstructor & { service: _mediaSignalingPackage_MediaSignalingDefinition } - SendMessageRequest: MessageTypeDefinition<_mediaSignalingPackage_SendMessageRequest, _mediaSignalingPackage_SendMessageRequest__Output> - SendMessageResponse: MessageTypeDefinition<_mediaSignalingPackage_SendMessageResponse, _mediaSignalingPackage_SendMessageResponse__Output> + MessageRequest: MessageTypeDefinition<_mediaSignalingPackage_MessageRequest, _mediaSignalingPackage_MessageRequest__Output> + MessageResponse: MessageTypeDefinition<_mediaSignalingPackage_MessageResponse, _mediaSignalingPackage_MessageResponse__Output> } } diff --git a/src/protos/gen/mediaSignalingPackage/MediaSignaling.ts b/src/protos/gen/mediaSignalingPackage/MediaSignaling.ts index ae0fe36..9ce9dc6 100644 --- a/src/protos/gen/mediaSignalingPackage/MediaSignaling.ts +++ b/src/protos/gen/mediaSignalingPackage/MediaSignaling.ts @@ -2,22 +2,22 @@ import type * as grpc from '@grpc/grpc-js' import type { MethodDefinition } from '@grpc/proto-loader' -import type { SendMessageRequest as _mediaSignalingPackage_SendMessageRequest, SendMessageRequest__Output as _mediaSignalingPackage_SendMessageRequest__Output } from '../mediaSignalingPackage/SendMessageRequest'; -import type { SendMessageResponse as _mediaSignalingPackage_SendMessageResponse, SendMessageResponse__Output as _mediaSignalingPackage_SendMessageResponse__Output } from '../mediaSignalingPackage/SendMessageResponse'; +import type { MessageRequest as _mediaSignalingPackage_MessageRequest, MessageRequest__Output as _mediaSignalingPackage_MessageRequest__Output } from '../mediaSignalingPackage/MessageRequest'; +import type { MessageResponse as _mediaSignalingPackage_MessageResponse, MessageResponse__Output as _mediaSignalingPackage_MessageResponse__Output } from '../mediaSignalingPackage/MessageResponse'; export interface MediaSignalingClient extends grpc.Client { - SendMessage(metadata: grpc.Metadata, options?: grpc.CallOptions): grpc.ClientDuplexStream<_mediaSignalingPackage_SendMessageRequest, _mediaSignalingPackage_SendMessageResponse__Output>; - SendMessage(options?: grpc.CallOptions): grpc.ClientDuplexStream<_mediaSignalingPackage_SendMessageRequest, _mediaSignalingPackage_SendMessageResponse__Output>; - sendMessage(metadata: grpc.Metadata, options?: grpc.CallOptions): grpc.ClientDuplexStream<_mediaSignalingPackage_SendMessageRequest, _mediaSignalingPackage_SendMessageResponse__Output>; - sendMessage(options?: grpc.CallOptions): grpc.ClientDuplexStream<_mediaSignalingPackage_SendMessageRequest, _mediaSignalingPackage_SendMessageResponse__Output>; + Message(metadata: grpc.Metadata, options?: grpc.CallOptions): grpc.ClientDuplexStream<_mediaSignalingPackage_MessageRequest, _mediaSignalingPackage_MessageResponse__Output>; + Message(options?: grpc.CallOptions): grpc.ClientDuplexStream<_mediaSignalingPackage_MessageRequest, _mediaSignalingPackage_MessageResponse__Output>; + message(metadata: grpc.Metadata, options?: grpc.CallOptions): grpc.ClientDuplexStream<_mediaSignalingPackage_MessageRequest, _mediaSignalingPackage_MessageResponse__Output>; + message(options?: grpc.CallOptions): grpc.ClientDuplexStream<_mediaSignalingPackage_MessageRequest, _mediaSignalingPackage_MessageResponse__Output>; } export interface MediaSignalingHandlers extends grpc.UntypedServiceImplementation { - SendMessage: grpc.handleBidiStreamingCall<_mediaSignalingPackage_SendMessageRequest__Output, _mediaSignalingPackage_SendMessageResponse>; + Message: grpc.handleBidiStreamingCall<_mediaSignalingPackage_MessageRequest__Output, _mediaSignalingPackage_MessageResponse>; } export interface MediaSignalingDefinition extends grpc.ServiceDefinition { - SendMessage: MethodDefinition<_mediaSignalingPackage_SendMessageRequest, _mediaSignalingPackage_SendMessageResponse, _mediaSignalingPackage_SendMessageRequest__Output, _mediaSignalingPackage_SendMessageResponse__Output> + Message: MethodDefinition<_mediaSignalingPackage_MessageRequest, _mediaSignalingPackage_MessageResponse, _mediaSignalingPackage_MessageRequest__Output, _mediaSignalingPackage_MessageResponse__Output> } diff --git a/src/protos/gen/mediaSignalingPackage/MessageRequest.ts b/src/protos/gen/mediaSignalingPackage/MessageRequest.ts new file mode 100644 index 0000000..4aa01e2 --- /dev/null +++ b/src/protos/gen/mediaSignalingPackage/MessageRequest.ts @@ -0,0 +1,12 @@ +// Original file: src/protos/media-signaling.proto + + +export interface MessageRequest { + 'action'?: (string); + 'args'?: (string); +} + +export interface MessageRequest__Output { + 'action'?: (string); + 'args'?: (string); +} diff --git a/src/protos/gen/mediaSignalingPackage/MessageResponse.ts b/src/protos/gen/mediaSignalingPackage/MessageResponse.ts new file mode 100644 index 0000000..ceb1084 --- /dev/null +++ b/src/protos/gen/mediaSignalingPackage/MessageResponse.ts @@ -0,0 +1,12 @@ +// Original file: src/protos/media-signaling.proto + + +export interface MessageResponse { + 'action'?: (string); + 'args'?: (string); +} + +export interface MessageResponse__Output { + 'action'?: (string); + 'args'?: (string); +} diff --git a/src/protos/gen/mediaSignalingPackage/SendMessageRequest.ts b/src/protos/gen/mediaSignalingPackage/SendMessageRequest.ts deleted file mode 100644 index 4ea3875..0000000 --- a/src/protos/gen/mediaSignalingPackage/SendMessageRequest.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Original file: src/protos/media-signaling.proto - - -export interface SendMessageRequest { - 'type'?: (string); - 'args'?: (string); -} - -export interface SendMessageRequest__Output { - 'type'?: (string); - 'args'?: (string); -} diff --git a/src/protos/gen/mediaSignalingPackage/SendMessageResponse.ts b/src/protos/gen/mediaSignalingPackage/SendMessageResponse.ts deleted file mode 100644 index 423bcf5..0000000 --- a/src/protos/gen/mediaSignalingPackage/SendMessageResponse.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Original file: src/protos/media-signaling.proto - - -export interface SendMessageResponse { - 'type'?: (string); - 'args'?: (string); -} - -export interface SendMessageResponse__Output { - 'type'?: (string); - 'args'?: (string); -} diff --git a/src/protos/media-signaling.proto b/src/protos/media-signaling.proto index 80bac0a..088f82a 100644 --- a/src/protos/media-signaling.proto +++ b/src/protos/media-signaling.proto @@ -1,21 +1,17 @@ syntax = "proto3"; + package mediaSignalingPackage; -// Service definition for bidirectional communication service MediaSignaling { - // Send Message - rpc SendMessage(stream SendMessageRequest) returns (stream SendMessageResponse) {}; + rpc Message(stream MessageRequest) returns (stream MessageResponse) {}; } - -// Health check messages -message SendMessageRequest { - string type = 1; +message MessageRequest { + string action = 1; string args = 2; } -message SendMessageResponse { - string type = 1; +message MessageResponse { + string action = 1; string args = 2; } - From 78b5dfd780974fa31a165f0256c89fcaa6d45fe5 Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 20:13:06 +0100 Subject: [PATCH 16/31] rename serverid to nodeid --- src/config/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/index.ts b/src/config/index.ts index 664a12a..fd9a786 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -17,7 +17,7 @@ const LISTEN_IP = process.env.LISTEN_IP || '0.0.0.0'; const ANNOUNCED_ADDRESS = process.env.ANNOUNCED_ADDRESS || '127.0.0.1'; const config = { - serverId: crypto.randomUUID(), + nodeId: `mnode-${crypto.randomUUID()}`, env: process.env.NODE_ENV, cors: { origin: process.env.NODE_ENV === 'production' ? ['https://mitsi.app'] : '*', From 678a95a5498dda32504d4ec6ceb8d651a331ece8 Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 20:13:20 +0100 Subject: [PATCH 17/31] use node id instead --- src/lib/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index dc8d50f..98551f0 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -21,7 +21,7 @@ export const registerMediaNode = async (): Promise => { const { publicIpv4 } = await import('public-ip'); const ip = await publicIpv4(); const medianodeData: MediaNodeData = { - id: ip || config.serverId, + id: ip || config.nodeId, ip: '0.0.0.0', address: `${config.port}`, grpcPort: `${config.grpcPort}`, From cd85682cd1a77b877b7b8313c78d4e131b3f3d5c Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 20:13:41 +0100 Subject: [PATCH 18/31] implement grpc-server optimization --- src/servers/grpc-server.ts | 486 +++++-------------------------------- 1 file changed, 57 insertions(+), 429 deletions(-) diff --git a/src/servers/grpc-server.ts b/src/servers/grpc-server.ts index 5c1c251..cad0bd9 100644 --- a/src/servers/grpc-server.ts +++ b/src/servers/grpc-server.ts @@ -1,24 +1,17 @@ -import * as grpc from '@grpc/grpc-js'; -import * as protoLoader from '@grpc/proto-loader'; import path from 'path'; import { EventEmitter } from 'events'; -import config from '../config'; -import { SendMessageRequest } from '../protos/gen/mediaSignalingPackage/SendMessageRequest'; -import { SendMessageResponse } from '../protos/gen/mediaSignalingPackage/SendMessageResponse'; +import * as grpc from '@grpc/grpc-js'; +import * as protoLoader from '@grpc/proto-loader'; + +import { MessageRequest } from '../protos/gen/mediaSignalingPackage/MessageRequest'; +import { MessageResponse } from '../protos/gen/mediaSignalingPackage/MessageResponse'; import { MediaSignalingHandlers } from '../protos/gen/mediaSignalingPackage/MediaSignaling'; import { ProtoGrpcType } from '../protos/gen/media-signaling'; -interface ClientConnection { - id: string; - nodeId?: string; - call: grpc.ServerDuplexStream; - metadata: grpc.Metadata; - connectedAt: Date; - lastActivity: Date; - isActive: boolean; - heartbeatInterval?: NodeJS.Timeout; -} +import config from '../config'; +import SignalNode from '../services/signalnode'; +import { MediaSignalingActions as MSA } from '../types/actions'; interface ServerStats { totalConnections: number; @@ -32,7 +25,6 @@ interface ServerStats { class GrpcServer extends EventEmitter { private static instance: GrpcServer | null = null; private server: grpc.Server; - private connections: Map; private isRunning: boolean; private startTime: Date; private stats: ServerStats; @@ -52,7 +44,6 @@ class GrpcServer extends EventEmitter { 'grpc.max_connection_age_grace_ms': 30000, }); - this.connections = new Map(); this.isRunning = false; this.startTime = new Date(); this.cleanupInterval = null; @@ -67,7 +58,6 @@ class GrpcServer extends EventEmitter { }; this.setup(); - this.setupCleanupInterval(); // Graceful shutdown handling process.on('SIGINT', () => this.gracefulShutdown('SIGINT')); @@ -103,14 +93,10 @@ class GrpcServer extends EventEmitter { this.startTime = new Date(); this.stats.startTime = this.startTime; + console.log(`gRPC Media Signaling Server started on port ${port}`); console.log( - `โœ… gRPC Media Signaling Server started on port ${port}` - ); - console.log( - `๐Ÿ“ก Server ready to accept connections at 0.0.0.0:${port}` + `Server ready to accept connections at 0.0.0.0:${port}` ); - - this.emit('serverStarted', { port }); resolve(); } ); @@ -121,39 +107,6 @@ class GrpcServer extends EventEmitter { } } - async stop(): Promise { - if (!this.isRunning) { - console.log('gRPC server is not running'); - return; - } - - console.log('๐Ÿ›‘ Stopping gRPC server...'); - - // Close all active connections first - await this.closeAllConnections(); - - // Clear cleanup interval - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = null; - } - - return new Promise(resolve => { - this.server.tryShutdown(error => { - if (error) { - console.error('Error during server shutdown:', error); - // Force shutdown if graceful shutdown fails - this.server.forceShutdown(); - } - - this.isRunning = false; - console.log('โœ… gRPC server stopped successfully'); - this.emit('serverStopped'); - resolve(); - }); - }); - } - private setup(): void { const PROTO_FILE = path.resolve( __dirname, @@ -167,370 +120,75 @@ class GrpcServer extends EventEmitter { const mediaSignaling = protoDescriptor.mediaSignalingPackage.MediaSignaling; this.server.addService(mediaSignaling.service, { - SendMessage: this.handleSendMessage.bind(this), + Message: this.handleMessage.bind(this), } as MediaSignalingHandlers); } - private handleSendMessage( - call: grpc.ServerDuplexStream + private handleMessage( + call: grpc.ServerDuplexStream ): void { - const clientId = this.extractClientId(call.metadata); const nodeId = this.extractNodeId(call.metadata); - const connection: ClientConnection = { - id: clientId, - nodeId, - call, - metadata: call.metadata, - connectedAt: new Date(), - lastActivity: new Date(), - isActive: true, - }; + new SignalNode({ id: nodeId, call }); - // Store connection - this.connections.set(clientId, connection); this.stats.totalConnections++; this.stats.activeConnections++; - - console.log( - `๐Ÿ“ฑ New client connected: ${clientId}${nodeId ? ` (Node: ${nodeId})` : ''}` - ); - console.log(`๐Ÿ“Š Active connections: ${this.stats.activeConnections}`); - - this.emit('clientConnected', { clientId, nodeId, connection }); - - // Setup heartbeat for this connection - this.setupHeartbeat(connection); - - // Handle incoming messages - call.on('data', (message: SendMessageRequest) => { - this.handleClientMessage(connection, message); - }); - - // Handle client disconnection - call.on('end', () => { - console.log(`๐Ÿ“ค Client ${clientId} ended the connection`); - this.handleClientDisconnection(clientId, 'client_ended'); - }); - - // Handle errors - call.on('error', (error: Error) => { - console.error(`โŒ Error with client ${clientId}:`, error.message); - this.handleClientDisconnection(clientId, 'error', error); - }); - - // Handle call cancellation - call.on('cancelled', () => { - console.log(`๐Ÿšซ Client ${clientId} cancelled the connection`); - this.handleClientDisconnection(clientId, 'cancelled'); - }); - - // Send initial connection confirmation - this.sendToClient(clientId, { - type: 'connection_confirmed', - args: JSON.stringify({ - status: 'success', - clientId, - serverTime: Date.now().toString(), - message: 'Successfully connected to Media Signaling Server', - }), - }); } - private handleClientMessage( - connection: ClientConnection, - message: SendMessageRequest - ): void { - connection.lastActivity = new Date(); - this.stats.totalMessagesReceived++; - - console.log( - `๐Ÿ“จ Message from client ${connection.id} (${message.type}):`, - message.args - ); - - this.emit('messageReceived', { - clientId: connection.id, - nodeId: connection.nodeId, - message, - connection, - }); - - // Handle specific message types - switch (message.type) { - case 'connect': - this.handleConnectMessage(connection, message); - break; - - case 'disconnect': - this.handleDisconnectMessage(connection, message); - break; - - case 'ping': - this.handlePingMessage(connection, message); - break; - - case 'media_request': - this.handleMediaRequest(connection, message); - break; - - default: - console.log(`๐Ÿ”„ Relaying message type: ${message.type}`); - this.relayMessageToNodes(connection, message); - break; + async stop(): Promise { + if (!this.isRunning) { + console.log('gRPC server is not running'); + return; } - } - - private handleConnectMessage( - connection: ClientConnection, - message: SendMessageRequest - ): void { - console.log(`โœ… Client ${connection.id} sent connect confirmation`); - - // Update connection info if nodeId is provided - console.log(message.args); - - this.sendToClient(connection.id, { - type: 'connect_ack', - args: JSON.stringify({ - status: 'acknowledged', - serverTime: Date.now(), - activeConnections: this.stats.activeConnections, - }), - }); - } - - private handleDisconnectMessage( - connection: ClientConnection, - message: SendMessageRequest - ): void { - console.log( - `๐Ÿ‘‹ Client ${connection.id} requested disconnection:`, - message.args - ); - - this.sendToClient(connection.id, { - type: 'disconnect_ack', - args: JSON.stringify({ - status: 'acknowledged', - message: 'Disconnection acknowledged', - }), - }); - - // Schedule connection cleanup - setTimeout(() => { - this.handleClientDisconnection(connection.id, 'client_requested'); - }, 1000); - } - - private handlePingMessage( - connection: ClientConnection, - message: SendMessageRequest - ): void { - // Respond to ping with pong - this.sendToClient(connection.id, { - type: 'pong', - args: JSON.stringify({ - timestamp: (message.args || Date.now()).toString(), - serverTime: Date.now().toString(), - }), - }); - } - - private handleMediaRequest( - connection: ClientConnection, - message: SendMessageRequest - ): void { - console.log(`๐ŸŽฌ Media request from ${connection.id}:`, message.args); - - // Process media request and respond - this.sendToClient(connection.id, { - type: 'media_response', - args: JSON.stringify({ - status: 'processing', - requestId: message.args || 'unknown', - message: 'Media request received and processing', - }), - }); - - // Emit event for external handling - this.emit('mediaRequest', { - clientId: connection.id, - nodeId: connection.nodeId, - request: message.args, - connection, - }); - } - - private relayMessageToNodes( - sender: ClientConnection, - message: SendMessageRequest - ): void { - // Relay message to other connected nodes (excluding sender) - let relayCount = 0; - - this.connections.forEach((connection, clientId) => { - if (clientId !== sender.id && connection.isActive && connection.nodeId) { - this.sendToClient(clientId, { - type: 'relay', - args: JSON.stringify({ - originalSender: sender.nodeId || sender.id, - originalMessage: message, - relayedAt: Date.now(), - }), - }); - relayCount++; - } - }); - - console.log(`๐Ÿ”„ Relayed message from ${sender.id} to ${relayCount} nodes`); - } + console.log('Stopping gRPC server...'); - private setupHeartbeat(connection: ClientConnection): void { - connection.heartbeatInterval = setInterval(() => { - if (!connection.isActive) { - return; - } - - // Check if connection is stale (no activity for 60 seconds) - const timeSinceLastActivity = - Date.now() - connection.lastActivity.getTime(); - if (timeSinceLastActivity > 60000) { - console.log(`๐Ÿ’” Connection ${connection.id} is stale, removing...`); - this.handleClientDisconnection(connection.id, 'stale_connection'); - return; - } - - // Send heartbeat - this.sendToClient(connection.id, { - type: 'heartbeat', - args: JSON.stringify({ - serverTime: Date.now(), - uptime: Date.now() - this.stats.startTime.getTime(), - }), - }); - }, 30000); // Send heartbeat every 30 seconds - } - - private handleClientDisconnection( - clientId: string, - reason: string, - error?: Error - ): void { - const connection = this.connections.get(clientId); - if (!connection) return; - - console.log(`๐Ÿ”Œ Client ${clientId} disconnected (${reason})`); + // Close all active connections first + await this.closeAllConnections(); - // Clear heartbeat - if (connection.heartbeatInterval) { - clearInterval(connection.heartbeatInterval); + // Clear cleanup interval + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; } - // Mark as inactive - connection.isActive = false; - - // Remove from connections - this.connections.delete(clientId); - this.stats.activeConnections = Math.max( - 0, - this.stats.activeConnections - 1 - ); - - console.log(`๐Ÿ“Š Active connections: ${this.stats.activeConnections}`); - - this.emit('clientDisconnected', { - clientId, - nodeId: connection.nodeId, - reason, - error, - connection, + return new Promise(resolve => { + this.server.tryShutdown(error => { + if (error) { + console.error('Error during server shutdown:', error); + // Force shutdown if graceful shutdown fails + this.server.forceShutdown(); + } + this.isRunning = false; + console.log('gRPC server stopped successfully'); + resolve(); + }); }); } - private sendToClient( - clientId: string, - message: SendMessageResponse - ): boolean { - const connection = this.connections.get(clientId); - if (!connection || !connection.isActive) { - console.warn( - `โš ๏ธ Cannot send message to client ${clientId}: connection not found or inactive` - ); - return false; - } - - try { - connection.call.write(message); - this.stats.totalMessagesSent++; - connection.lastActivity = new Date(); - return true; - } catch (error) { - console.error(`โŒ Error sending message to client ${clientId}:`, error); - this.handleClientDisconnection(clientId, 'send_error', error as Error); - return false; - } - } - - private extractClientId(metadata: grpc.Metadata): string { - const clientId = metadata.get('clientId')?.[0]; - return typeof clientId === 'string' ? clientId : crypto.randomUUID(); - } - - private extractNodeId(metadata: grpc.Metadata): string | undefined { + private extractNodeId(metadata: grpc.Metadata): string { const nodeId = metadata.get('nodeId')?.[0]; - return typeof nodeId === 'string' ? nodeId : undefined; - } - - private setupCleanupInterval(): void { - this.cleanupInterval = setInterval(() => { - this.performCleanup(); - }, 60000); // Run cleanup every minute - } - - private performCleanup(): void { - const now = Date.now(); - let cleanedUp = 0; - - this.connections.forEach((connection, clientId) => { - const timeSinceLastActivity = now - connection.lastActivity.getTime(); - - // Remove connections inactive for more than 5 minutes - if (timeSinceLastActivity > 300000) { - console.log(`๐Ÿงน Cleaning up stale connection: ${clientId}`); - this.handleClientDisconnection(clientId, 'cleanup_stale'); - cleanedUp++; - } - }); - - if (cleanedUp > 0) { - console.log(`๐Ÿงน Cleaned up ${cleanedUp} stale connections`); - } - - // Update uptime - this.stats.uptime = now - this.stats.startTime.getTime(); + return typeof nodeId === 'string' ? nodeId : `snode-${crypto.randomUUID()}`; } private async closeAllConnections(): Promise { - console.log(`๐Ÿ”Œ Closing ${this.connections.size} active connections...`); + console.log( + `๐Ÿ”Œ Closing ${SignalNode.getNodes().length} active connections...` + ); const closePromises: Promise[] = []; - this.connections.forEach((connection, clientId) => { + SignalNode.getNodes().forEach(node => { closePromises.push( new Promise(resolve => { // Send shutdown notification - this.sendToClient(clientId, { - type: 'server_shutdown', - args: JSON.stringify({ - message: 'Server is shutting down', - timestamp: Date.now().toString(), - }), + node.sendMessage(MSA.ServerShutdown, { + message: 'Server is shutting down', + timestamp: Date.now().toString(), }); // Clean up connection setTimeout(() => { - this.handleClientDisconnection(clientId, 'server_shutdown'); + // this.handleClientDisconnection(clientId, 'server_shutdown'); resolve(); }, 1000); }) @@ -538,18 +196,19 @@ class GrpcServer extends EventEmitter { }); await Promise.all(closePromises); - console.log('โœ… All connections closed'); + console.log('All connections closed'); } private async gracefulShutdown(signal: string): Promise { - console.log(`\n๐Ÿ›‘ Received ${signal}, starting graceful shutdown...`); + console.log(`\nReceived ${signal}, starting graceful shutdown...`); try { await this.stop(); - console.log('โœ… Graceful shutdown completed'); + this.removeAllListeners(); + console.log('Graceful shutdown completed'); process.exit(0); } catch (error) { - console.error('โŒ Error during graceful shutdown:', error); + console.error('Error during graceful shutdown:', error); process.exit(1); } } @@ -559,47 +218,16 @@ class GrpcServer extends EventEmitter { return { ...this.stats, uptime: Date.now() - this.stats.startTime.getTime(), - activeConnections: this.connections.size, }; } - public getConnections(): ClientConnection[] { - return Array.from(this.connections.values()); - } - - public getConnection(clientId: string): ClientConnection | undefined { - return this.connections.get(clientId); - } - - public broadcastMessage( - message: SendMessageResponse, - excludeClient?: string - ): number { - let sentCount = 0; - - this.connections.forEach((connection, clientId) => { - if (clientId !== excludeClient && connection.isActive) { - if (this.sendToClient(clientId, message)) { - sentCount++; - } - } + public broadcastMessage(message: { + type: MSA; + args?: { [key: string]: unknown }; + }): void { + SignalNode.getNodes().forEach(node => { + node.sendMessage(message.type, message.args); }); - - console.log(`๐Ÿ“ข Broadcasted message to ${sentCount} clients`); - return sentCount; - } - - public sendToNode(nodeId: string, message: SendMessageResponse): boolean { - const connection = Array.from(this.connections.values()).find( - conn => conn.nodeId === nodeId && conn.isActive - ); - - if (!connection) { - console.warn(`โš ๏ธ Node ${nodeId} not found or inactive`); - return false; - } - - return this.sendToClient(connection.id, message); } public checkIsRunning(): boolean { From d36be74b80e865165df702bf58aee3a55c0b3b85 Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 20:14:01 +0100 Subject: [PATCH 19/31] rename pubsubevents to pubsubactions --- src/servers/redis-server.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/servers/redis-server.ts b/src/servers/redis-server.ts index be4cd8c..fce89d4 100644 --- a/src/servers/redis-server.ts +++ b/src/servers/redis-server.ts @@ -1,7 +1,7 @@ import { createClient, RedisClientType, SetOptions } from 'redis'; import config from '../config'; -import { PubSubEvents } from '../types/events'; +import { PubSubActions } from '../types/actions'; class RedisServer { private static instance: RedisServer | null = null; @@ -40,12 +40,12 @@ class RedisServer { private async subscribe(): Promise { if (!this.isConnected) throw new Error('Redis clients are not connected. Call connect() first'); - await this.subClient.subscribe(PubSubEvents.Message, message => { + await this.subClient.subscribe(PubSubActions.Message, message => { const { event, args, }: { - event: PubSubEvents; + event: PubSubActions; args: { [key: string]: unknown }; } = JSON.parse(message); @@ -61,14 +61,14 @@ class RedisServer { event, args, }: { - event: PubSubEvents; + event: PubSubActions; args: { [key: string]: unknown }; }): Promise { if (!this.isConnected) throw new Error('Redis clients are not connected. Call connect() first'); const message = JSON.stringify({ event, args }); - await this.pubClient.publish(PubSubEvents.Message, message); + await this.pubClient.publish(PubSubActions.Message, message); console.info(`Message published to channe ${message}`); } From ec6da1fc864868f5f83a3c7095a49681b618385a Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 20:15:34 +0100 Subject: [PATCH 20/31] change get key to get redis key --- src/services/room.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/services/room.ts b/src/services/room.ts index bcbead5..7002b3d 100644 --- a/src/services/room.ts +++ b/src/services/room.ts @@ -5,7 +5,7 @@ import config from '../config'; import { mediaSoupServer } from '../servers/mediasoup-server'; import { redisServer } from '../servers/redis-server'; import { getKey } from '../lib/utils'; -import { ServiceEvents } from '../types/events'; +import { ServiceActions } from '../types/actions'; import { PipeConsumerParams } from '../types'; import MediaNode from './medianode'; @@ -317,7 +317,7 @@ class Room extends EventEmitter { }; private handlePeerEvents(peer: Peer): void { - peer.on(ServiceEvents.Close, () => { + peer.on(ServiceActions.Close, () => { if (!this.getPeer(peer.id)) return; this.removePeer(peer.id); }); @@ -414,7 +414,7 @@ class Room extends EventEmitter { addMediaNode(mediaNode: MediaNode): void { this.mediaNodes.set(mediaNode.id, mediaNode); - mediaNode.on(ServiceEvents.Close, () => { + mediaNode.on(ServiceActions.Close, () => { this.mediaNodes.delete(mediaNode.id); }); } From fb74057cb24acbeb2d94ffa8e8585298ef61880e Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 20:15:47 +0100 Subject: [PATCH 21/31] change name --- src/services/room.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/room.ts b/src/services/room.ts index 7002b3d..7bdb40f 100644 --- a/src/services/room.ts +++ b/src/services/room.ts @@ -4,7 +4,7 @@ import Peer from './peer'; import config from '../config'; import { mediaSoupServer } from '../servers/mediasoup-server'; import { redisServer } from '../servers/redis-server'; -import { getKey } from '../lib/utils'; +import { getRedisKey } from '../lib/utils'; import { ServiceActions } from '../types/actions'; import { PipeConsumerParams } from '../types'; import MediaNode from './medianode'; @@ -294,7 +294,7 @@ class Room extends EventEmitter { // store meeting active speaker peerid in db. // todo may require a different position when optimising for multiple media servers] redisServer.set( - getKey['roomActiveSpeakerPeerId'](this.roomId), + getRedisKey['roomActiveSpeakerPeerId'](this.roomId), JSON.stringify(peerId) ); From 3cd82ec2609e3aa407af9a00fec5ca9b9d0bbc98 Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 20:16:25 +0100 Subject: [PATCH 22/31] implement signalnode --- src/services/signalnode.ts | 132 ++++++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/src/services/signalnode.ts b/src/services/signalnode.ts index 862b5ca..6081531 100644 --- a/src/services/signalnode.ts +++ b/src/services/signalnode.ts @@ -1,9 +1,139 @@ import EventEmitter from 'events'; +import * as grpc from '@grpc/grpc-js'; + +import { MediaSignalingActions as MSA } from '../types/actions'; +import { MessageRequest } from '../protos/gen/mediaSignalingPackage/MessageRequest'; +import { MessageResponse } from '../protos/gen/mediaSignalingPackage/MessageResponse'; class SignalNode extends EventEmitter { - constructor() { + id: string; // client Id; + call: grpc.ServerDuplexStream; + metadata: grpc.Metadata; + connectedAt: number; + lastActivity: number; + isActive: boolean; + heartbeatInterval?: NodeJS.Timeout; + + static signalNodes: Map; + + constructor({ + id, + call, + }: { + id: string; + call: grpc.ServerDuplexStream; + }) { super(); + this.id = id; + this.call = call; + this.metadata = call.metadata; + this.connectedAt = Date.now(); + this.lastActivity = Date.now(); + this.isActive = true; + + SignalNode.signalNodes.set(id, this); + this.handleMessages(); + this.setupHeartbeat(); + console.log('new grpc connection - signalnode connected nodeId', this.id); + } + + static getNodes(): SignalNode[] { + return Array.from(SignalNode.signalNodes.values()); + } + + private setupHeartbeat(): void { + this.heartbeatInterval = setInterval(() => { + if (!this.isActive) { + return; + } + const timeSinceLastActivity = Date.now() - this.lastActivity; + if (timeSinceLastActivity > 60000) { + console.log(`Connection ${this.id} is stale, removing...`); + this.handleClientDisconnection('stale_connection'); + return; + } + + // Send heartbeat + this.sendMessage(MSA.Heartbeat); + }, 30000); // Send heartbeat every 30 seconds + } + + private handleMessages(): void { + // Handle incoming messages + + this.call.on('data', (message: MessageRequest) => { + const { action, args } = message; + if (!action) return; + const handler = this.actionHandlers[action as MSA]; + + if (handler) handler(args && JSON.parse(args)); + }); + + // Handle client disconnection + this.call.on('end', () => { + console.log(`Client ${this.id} ended the connection`); + this.handleClientDisconnection('client_ended'); + }); + + // Handle call cancellation + this.call.on('cancelled', () => { + console.log(`Client ${this.id} cancelled the connection`); + this.handleClientDisconnection('cancelled'); + }); + + this.sendMessage(MSA.Connected, { + status: 'success', + nodeId: this.id, + message: 'Successfully connected to Media Signaling Server', + }); + } + + private handleClientDisconnection(reason: string, error?: Error): void { + console.log(`๐Ÿ”Œ Client ${this.id} disconnected (${reason}) - `, error); + + // Clear heartbeat + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + } + + // Mark as inactive + this.isActive = false; + + // Remove from connections + // this.connections.delete(clientId); + // this.stats.activeConnections = Math.max( + // 0, + // this.stats.activeConnections - 1 + // ); } + + sendMessage(action: MSA, args?: { [key: string]: unknown }): boolean { + if (!this.isActive) { + console.warn( + `โš ๏ธ Cannot send message to client ${this.id}: signalNode is inactive` + ); + return false; + } + try { + this.call.write({ + action, + args: JSON.stringify(args || {}), + }); + this.lastActivity = Date.now(); + return true; + } catch (error) { + console.error(` Error sending message to client ${this.id}:`, error); + return false; + } + } + + private actionHandlers: { + [key in MSA]?: (args: { [key: string]: unknown }) => void; + } = { + connected: args => { + console.log(args); + }, + }; } export default SignalNode; From 73be983daced6cd2c115c821df12d3901e086d7d Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 20:16:41 +0100 Subject: [PATCH 23/31] change events to actions --- src/types/events.ts | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 src/types/events.ts diff --git a/src/types/events.ts b/src/types/events.ts deleted file mode 100644 index a5fdc40..0000000 --- a/src/types/events.ts +++ /dev/null @@ -1,17 +0,0 @@ -export enum SignallingEvents { - Message = 'message', - Connected = 'connected', - JoinRoom = 'join-room', - JoinVisitors = 'join-visitors', - JoinWaiters = 'join-waiters', - GetRoomData = 'get-room-data', - GetRtpCapabilities = 'get-rtp-capabilities', -} -export enum PubSubEvents { - Message = 'message', - EndMeeting = 'end-meeting', -} - -export enum ServiceEvents { - Close = 'close', -} From cb5d4d682d0c7fb9e788c7689556f93db5f9f0bb Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 20:16:48 +0100 Subject: [PATCH 24/31] add actions --- src/types/actions.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/types/actions.ts diff --git a/src/types/actions.ts b/src/types/actions.ts new file mode 100644 index 0000000..7d4dc53 --- /dev/null +++ b/src/types/actions.ts @@ -0,0 +1,24 @@ +export enum SignalingClientActions { + Message = 'message', + Heartbeat = 'heartbeat', + Connected = 'connected', + JoinRoom = 'join-room', + JoinVisitors = 'join-visitors', + JoinWaiters = 'join-waiters', + GetRoomData = 'get-room-data', + GetRtpCapabilities = 'get-rtp-capabilities', +} +export enum PubSubActions { + Message = 'message', + EndMeeting = 'end-meeting', +} + +export enum ServiceActions { + Close = 'close', +} + +export enum MediaSignalingActions { + Connected = 'connected', + Heartbeat = 'heartbeat', + ServerShutdown = 'server-shutdown', +} From 2c1e2fb1a8f4be5093bff7ffae6b3ff13b283a1b Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 21:10:02 +0100 Subject: [PATCH 25/31] change nodeid to client id --- src/servers/grpc-server.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/servers/grpc-server.ts b/src/servers/grpc-server.ts index cad0bd9..97f364e 100644 --- a/src/servers/grpc-server.ts +++ b/src/servers/grpc-server.ts @@ -127,9 +127,9 @@ class GrpcServer extends EventEmitter { private handleMessage( call: grpc.ServerDuplexStream ): void { - const nodeId = this.extractNodeId(call.metadata); + const clientId = this.extractClientId(call.metadata); - new SignalNode({ id: nodeId, call }); + new SignalNode({ id: clientId, call }); this.stats.totalConnections++; this.stats.activeConnections++; @@ -165,9 +165,11 @@ class GrpcServer extends EventEmitter { }); } - private extractNodeId(metadata: grpc.Metadata): string { - const nodeId = metadata.get('nodeId')?.[0]; - return typeof nodeId === 'string' ? nodeId : `snode-${crypto.randomUUID()}`; + private extractClientId(metadata: grpc.Metadata): string { + const clientId = metadata.get('clientId')?.[0]; + return typeof clientId === 'string' + ? clientId + : `snode-${crypto.randomUUID()}`; } private async closeAllConnections(): Promise { From 121efb096d0ded85afd4662793261559a74e6bba Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Sun, 17 Aug 2025 23:44:14 +0100 Subject: [PATCH 26/31] fix server node error --- src/servers/grpc-server.ts | 9 +-------- src/services/signalnode.ts | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/servers/grpc-server.ts b/src/servers/grpc-server.ts index 97f364e..57e407c 100644 --- a/src/servers/grpc-server.ts +++ b/src/servers/grpc-server.ts @@ -127,7 +127,7 @@ class GrpcServer extends EventEmitter { private handleMessage( call: grpc.ServerDuplexStream ): void { - const clientId = this.extractClientId(call.metadata); + const clientId = call.metadata.get('clientid')[0].toString(); new SignalNode({ id: clientId, call }); @@ -165,13 +165,6 @@ class GrpcServer extends EventEmitter { }); } - private extractClientId(metadata: grpc.Metadata): string { - const clientId = metadata.get('clientId')?.[0]; - return typeof clientId === 'string' - ? clientId - : `snode-${crypto.randomUUID()}`; - } - private async closeAllConnections(): Promise { console.log( `๐Ÿ”Œ Closing ${SignalNode.getNodes().length} active connections...` diff --git a/src/services/signalnode.ts b/src/services/signalnode.ts index 6081531..a76fcac 100644 --- a/src/services/signalnode.ts +++ b/src/services/signalnode.ts @@ -14,7 +14,7 @@ class SignalNode extends EventEmitter { isActive: boolean; heartbeatInterval?: NodeJS.Timeout; - static signalNodes: Map; + static signalNodes = new Map(); constructor({ id, From 5cdae4261c14fdca220f009ecb39907552a2ab87 Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Tue, 19 Aug 2025 11:53:03 +0100 Subject: [PATCH 27/31] fix error in grpc server and signal node --- src/servers/grpc-server.ts | 470 +++++++++++++++++++----- src/services/signalnode.ts | 718 ++++++++++++++++++++++++++++++++++--- src/types/actions.ts | 32 +- 3 files changed, 1084 insertions(+), 136 deletions(-) diff --git a/src/servers/grpc-server.ts b/src/servers/grpc-server.ts index 57e407c..5052762 100644 --- a/src/servers/grpc-server.ts +++ b/src/servers/grpc-server.ts @@ -20,18 +20,53 @@ interface ServerStats { totalMessagesSent: number; uptime: number; startTime: Date; + errors: { + connectionErrors: number; + messageErrors: number; + protocolErrors: number; + }; +} + +interface ServerConfig { + maxConnections: number; + messageTimeout: number; + shutdownGracePeriod: number; + healthCheckInterval: number; + metricsReportInterval: number; +} + +enum ServerState { + STOPPED = 'STOPPED', + STARTING = 'STARTING', + RUNNING = 'RUNNING', + STOPPING = 'STOPPING', + ERROR = 'ERROR', } class GrpcServer extends EventEmitter { private static instance: GrpcServer | null = null; private server: grpc.Server; - private isRunning: boolean; + private serverState: ServerState; private startTime: Date; private stats: ServerStats; private cleanupInterval: NodeJS.Timeout | null; + private healthCheckInterval: NodeJS.Timeout | null; + private metricsInterval: NodeJS.Timeout | null; + private readonly config: ServerConfig; + private shutdownPromise: Promise | null = null; - private constructor() { + private constructor(serverConfig?: Partial) { super(); + + this.config = { + maxConnections: 1000, + messageTimeout: 30000, + shutdownGracePeriod: 10000, + healthCheckInterval: 30000, + metricsReportInterval: 60000, + ...serverConfig, + }; + this.server = new grpc.Server({ 'grpc.keepalive_time_ms': 10000, 'grpc.keepalive_timeout_ms': 5000, @@ -42,11 +77,15 @@ class GrpcServer extends EventEmitter { 'grpc.max_connection_idle_ms': 300000, 'grpc.max_connection_age_ms': 600000, 'grpc.max_connection_age_grace_ms': 30000, + 'grpc.max_receive_message_length': 4 * 1024 * 1024, // 4MB + 'grpc.max_send_message_length': 4 * 1024 * 1024, // 4MB }); - this.isRunning = false; + this.serverState = ServerState.STOPPED; this.startTime = new Date(); this.cleanupInterval = null; + this.healthCheckInterval = null; + this.metricsInterval = null; this.stats = { totalConnections: 0, @@ -55,159 +94,392 @@ class GrpcServer extends EventEmitter { totalMessagesSent: 0, uptime: 0, startTime: this.startTime, + errors: { + connectionErrors: 0, + messageErrors: 0, + protocolErrors: 0, + }, }; this.setup(); + this.startPeriodicTasks(); // Graceful shutdown handling process.on('SIGINT', () => this.gracefulShutdown('SIGINT')); process.on('SIGTERM', () => this.gracefulShutdown('SIGTERM')); + process.on('uncaughtException', error => + this.handleUncaughtException(error) + ); } - static getInstance(): GrpcServer { + static getInstance(serverConfig?: Partial): GrpcServer { if (!GrpcServer.instance) { - GrpcServer.instance = new GrpcServer(); + GrpcServer.instance = new GrpcServer(serverConfig); } return GrpcServer.instance; } async start(): Promise { - if (this.isRunning) { - console.log('gRPC server is already running'); + if (this.serverState === ServerState.RUNNING) { + console.log('๐Ÿ”„ gRPC server is already running'); return; } + if (this.serverState === ServerState.STARTING) { + console.log('โณ gRPC server is already starting'); + return; + } + + this.setState(ServerState.STARTING); + try { await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error('Server start timeout')); + }, 30000); + this.server.bindAsync( `0.0.0.0:${config.grpcPort}`, grpc.ServerCredentials.createInsecure(), (err, port) => { + clearTimeout(timeoutId); + if (err) { - console.error('Failed to bind gRPC server:', err); + console.error('โŒ Failed to bind gRPC server:', err); + this.setState(ServerState.ERROR); + this.stats.errors.connectionErrors++; reject(err); return; } - this.isRunning = true; + this.setState(ServerState.RUNNING); this.startTime = new Date(); this.stats.startTime = this.startTime; - console.log(`gRPC Media Signaling Server started on port ${port}`); - console.log( - `Server ready to accept connections at 0.0.0.0:${port}` - ); + console.log(`โœ… gRPC Media Signaling Server started successfully`); + console.log(`๐ŸŒ Server listening on 0.0.0.0:${port}`); + console.log(`๐Ÿ“Š Max connections: ${this.config.maxConnections}`); + + this.emit('serverStarted', { port, timestamp: new Date() }); resolve(); } ); }); } catch (error) { - console.error('Error starting gRPC server:', error); + console.error('๐Ÿ’ฅ Critical error starting gRPC server:', error); + this.setState(ServerState.ERROR); + this.emit('serverError', { error, timestamp: new Date() }); throw error; } } private setup(): void { - const PROTO_FILE = path.resolve( - __dirname, - '../protos/media-signaling.proto' - ); - const packageDefinition = protoLoader.loadSync(PROTO_FILE); - const protoDescriptor = grpc.loadPackageDefinition( - packageDefinition - ) as unknown as ProtoGrpcType; + try { + const PROTO_FILE = path.resolve( + __dirname, + '../protos/media-signaling.proto' + ); + + const packageDefinition = protoLoader.loadSync(PROTO_FILE, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, + }); + + const protoDescriptor = grpc.loadPackageDefinition( + packageDefinition + ) as unknown as ProtoGrpcType; + + const mediaSignaling = + protoDescriptor.mediaSignalingPackage.MediaSignaling; - const mediaSignaling = protoDescriptor.mediaSignalingPackage.MediaSignaling; + this.server.addService(mediaSignaling.service, { + Message: this.handleMessage.bind(this), + } as MediaSignalingHandlers); - this.server.addService(mediaSignaling.service, { - Message: this.handleMessage.bind(this), - } as MediaSignalingHandlers); + console.log('๐Ÿ“‹ gRPC service definitions loaded successfully'); + } catch (error) { + console.error('โŒ Failed to setup gRPC service:', error); + throw error; + } } private handleMessage( call: grpc.ServerDuplexStream ): void { - const clientId = call.metadata.get('clientid')[0].toString(); + const connectionId = this.generateConnectionId(); + const clientMetadata = call.metadata; + const clientId = + clientMetadata.get('clientid')[0]?.toString() || connectionId; + const remoteAddress = call.getPeer(); + + console.log(`๐Ÿ”Œ New gRPC connection established`); + console.log(` Client ID: ${clientId}`); + console.log(` Connection ID: ${connectionId}`); + console.log(` Remote Address: ${remoteAddress}`); + + // Check connection limits + if (this.stats.activeConnections >= this.config.maxConnections) { + console.warn( + `โš ๏ธ Connection limit reached (${this.config.maxConnections}), rejecting client ${clientId}` + ); + call.destroy(new Error('Server connection limit reached')); + return; + } - new SignalNode({ id: clientId, call }); + try { + // Create SignalNode with enhanced error handling + new SignalNode({ + id: clientId, + call, + connectionId, + }); + + // Update server stats + this.stats.totalConnections++; + this.stats.activeConnections++; + + // Set up connection cleanup + const cleanup = (): void => { + this.stats.activeConnections = Math.max( + 0, + this.stats.activeConnections - 1 + ); + this.emit('connectionClosed', { + clientId, + connectionId, + timestamp: new Date(), + activeConnections: this.stats.activeConnections, + }); + }; + + call.on('close', cleanup); + call.on('cancelled', cleanup); + + this.emit('connectionOpened', { + clientId, + connectionId, + remoteAddress, + timestamp: new Date(), + activeConnections: this.stats.activeConnections, + }); + } catch (error) { + console.error( + `โŒ Error creating SignalNode for client ${clientId}:`, + error + ); + this.stats.errors.connectionErrors++; + // call.destroy(error); + } + } + + private generateConnectionId(): string { + return `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + private setState(newState: ServerState): void { + if (this.serverState !== newState) { + const oldState = this.serverState; + this.serverState = newState; + console.log(`๐Ÿ“ก Server state changed: ${oldState} -> ${newState}`); + this.emit('stateChanged', { oldState, newState, timestamp: new Date() }); + } + } + + private startPeriodicTasks(): void { + // Health check interval + this.healthCheckInterval = setInterval(() => { + this.performHealthCheck(); + }, this.config.healthCheckInterval); + + // Metrics reporting interval + this.metricsInterval = setInterval(() => { + this.reportMetrics(); + }, this.config.metricsReportInterval); + + // Cleanup interval for stale connections + this.cleanupInterval = setInterval(() => { + this.cleanupStaleConnections(); + }, 60000); // Every minute + } + + private performHealthCheck(): void { + try { + const stats = this.getStats(); + const memoryUsage = process.memoryUsage(); + + console.log( + `๐Ÿ’— Health Check - Active: ${stats.activeConnections}, Memory: ${Math.round(memoryUsage.heapUsed / 1024 / 1024)}MB` + ); + + this.emit('healthCheck', { + stats, + memoryUsage, + timestamp: new Date(), + }); + + // Check for memory leaks + if (memoryUsage.heapUsed > 512 * 1024 * 1024) { + // 512MB threshold + console.warn('โš ๏ธ High memory usage detected:', memoryUsage); + this.emit('memoryWarning', { memoryUsage, timestamp: new Date() }); + } + } catch (error) { + console.error('โŒ Health check failed:', error); + } + } + + private reportMetrics(): void { + const stats = this.getStats(); + console.log(`๐Ÿ“Š Server Metrics:`, { + uptime: `${Math.round(stats.uptime / 1000 / 60)}min`, + connections: `${stats.activeConnections}/${stats.totalConnections}`, + messages: `R:${stats.totalMessagesReceived} S:${stats.totalMessagesSent}`, + errors: stats.errors, + }); - this.stats.totalConnections++; - this.stats.activeConnections++; + this.emit('metricsReport', { stats, timestamp: new Date() }); + } + + private cleanupStaleConnections(): void { + try { + const nodes = SignalNode.getNodes(); + const staleThreshold = 5 * 60 * 1000; // 5 minutes + let cleanedCount = 0; + + nodes.forEach(node => { + if (node.isStale(staleThreshold)) { + console.log(`๐Ÿงน Cleaning up stale connection: ${node.id}`); + node.forceDisconnect('stale_connection_cleanup'); + cleanedCount++; + } + }); + + if (cleanedCount > 0) { + console.log(`๐Ÿงน Cleaned up ${cleanedCount} stale connections`); + } + } catch (error) { + console.error('โŒ Error during cleanup:', error); + } } async stop(): Promise { - if (!this.isRunning) { - console.log('gRPC server is not running'); + if (this.shutdownPromise) { + return this.shutdownPromise; + } + + if (this.serverState === ServerState.STOPPED) { + console.log('โน๏ธ gRPC server is already stopped'); return; } - console.log('Stopping gRPC server...'); - // Close all active connections first - await this.closeAllConnections(); + this.setState(ServerState.STOPPING); + console.log('โน๏ธ Stopping gRPC server...'); + + this.shutdownPromise = this.performShutdown(); + return this.shutdownPromise; + } + + private async performShutdown(): Promise { + try { + // Stop accepting new connections + console.log('๐Ÿšซ Stopping new connection acceptance...'); + + // Close all active connections gracefully + await this.closeAllConnections(); + + // Clear all intervals + this.clearIntervals(); + + // Shutdown server + await new Promise(resolve => { + const timeoutId = setTimeout(() => { + console.warn('โš ๏ธ Graceful shutdown timeout, forcing shutdown'); + this.server.forceShutdown(); + resolve(); + }, this.config.shutdownGracePeriod); + + this.server.tryShutdown(error => { + clearTimeout(timeoutId); + if (error) { + console.error('โŒ Error during server shutdown:', error); + this.server.forceShutdown(); + } + resolve(); + }); + }); + + this.setState(ServerState.STOPPED); + console.log('โœ… gRPC server stopped successfully'); + this.emit('serverStopped', { timestamp: new Date() }); + } catch (error) { + console.error('๐Ÿ’ฅ Error during shutdown:', error); + this.setState(ServerState.ERROR); + throw error; + } finally { + this.shutdownPromise = null; + } + } - // Clear cleanup interval + private clearIntervals(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } - - return new Promise(resolve => { - this.server.tryShutdown(error => { - if (error) { - console.error('Error during server shutdown:', error); - // Force shutdown if graceful shutdown fails - this.server.forceShutdown(); - } - this.isRunning = false; - console.log('gRPC server stopped successfully'); - resolve(); - }); - }); + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; + } + if (this.metricsInterval) { + clearInterval(this.metricsInterval); + this.metricsInterval = null; + } } private async closeAllConnections(): Promise { - console.log( - `๐Ÿ”Œ Closing ${SignalNode.getNodes().length} active connections...` - ); - - const closePromises: Promise[] = []; + const nodes = SignalNode.getNodes(); + console.log(`๐Ÿ”Œ Closing ${nodes.length} active connections...`); - SignalNode.getNodes().forEach(node => { - closePromises.push( - new Promise(resolve => { - // Send shutdown notification - node.sendMessage(MSA.ServerShutdown, { - message: 'Server is shutting down', - timestamp: Date.now().toString(), - }); + if (nodes.length === 0) { + return; + } - // Clean up connection - setTimeout(() => { - // this.handleClientDisconnection(clientId, 'server_shutdown'); - resolve(); - }, 1000); - }) - ); - }); + const closePromises = nodes.map(node => + node.gracefulDisconnect('server_shutdown').catch(error => { + console.warn(`โš ๏ธ Error closing connection ${node.id}:`, error); + }) + ); - await Promise.all(closePromises); - console.log('All connections closed'); + await Promise.allSettled(closePromises); + console.log('โœ… All connections closed'); } private async gracefulShutdown(signal: string): Promise { - console.log(`\nReceived ${signal}, starting graceful shutdown...`); + console.log(`\n๐Ÿ›‘ Received ${signal}, starting graceful shutdown...`); try { await this.stop(); this.removeAllListeners(); - console.log('Graceful shutdown completed'); + console.log('โœ… Graceful shutdown completed'); process.exit(0); } catch (error) { - console.error('Error during graceful shutdown:', error); + console.error('๐Ÿ’ฅ Error during graceful shutdown:', error); process.exit(1); } } + private handleUncaughtException(error: Error): void { + console.error('๐Ÿšจ Uncaught Exception:', error); + this.emit('uncaughtException', { error, timestamp: new Date() }); + + // Attempt graceful shutdown + this.gracefulShutdown('uncaughtException').catch(() => { + process.exit(1); + }); + } + // Public API methods public getStats(): ServerStats { return { @@ -216,18 +488,56 @@ class GrpcServer extends EventEmitter { }; } + public getState(): ServerState { + return this.serverState; + } + + public isRunning(): boolean { + return this.serverState === ServerState.RUNNING; + } + + public incrementMessageStats(sent: number = 0, received: number = 0): void { + this.stats.totalMessagesSent += sent; + this.stats.totalMessagesReceived += received; + } + + public incrementErrorStats(type: keyof ServerStats['errors']): void { + this.stats.errors[type]++; + } + public broadcastMessage(message: { type: MSA; args?: { [key: string]: unknown }; - }): void { - SignalNode.getNodes().forEach(node => { - node.sendMessage(message.type, message.args); + excludeIds?: string[]; + }): { sent: number; failed: number } { + const nodes = SignalNode.getNodes(); + const eligibleNodes = message.excludeIds + ? nodes.filter(node => !message.excludeIds!.includes(node.id)) + : nodes; + + let sent = 0; + let failed = 0; + + eligibleNodes.forEach(node => { + if (node.sendMessage(message.type, message.args)) { + sent++; + } else { + failed++; + } }); + + console.log(`๐Ÿ“ข Broadcast ${message.type}: ${sent} sent, ${failed} failed`); + return { sent, failed }; + } + + public getConnectionById(id: string): SignalNode | undefined { + return SignalNode.getNodeById(id); } - public checkIsRunning(): boolean { - return this.isRunning; + public getAllConnections(): SignalNode[] { + return SignalNode.getNodes(); } } export const grpcServer = GrpcServer.getInstance(); +export { ServerState, GrpcServer }; diff --git a/src/services/signalnode.ts b/src/services/signalnode.ts index a76fcac..94fa675 100644 --- a/src/services/signalnode.ts +++ b/src/services/signalnode.ts @@ -4,136 +4,744 @@ import * as grpc from '@grpc/grpc-js'; import { MediaSignalingActions as MSA } from '../types/actions'; import { MessageRequest } from '../protos/gen/mediaSignalingPackage/MessageRequest'; import { MessageResponse } from '../protos/gen/mediaSignalingPackage/MessageResponse'; +import { grpcServer } from '../servers/grpc-server'; + +enum ConnectionState { + CONNECTING = 'CONNECTING', + CONNECTED = 'CONNECTED', + DISCONNECTING = 'DISCONNECTING', + DISCONNECTED = 'DISCONNECTED', + ERROR = 'ERROR', +} + +interface ConnectionMetrics { + connectedAt: number; + lastActivity: number; + lastHeartbeat: number; + messagesSent: number; + messagesReceived: number; + errors: number; + heartbeatsReceived: number; + heartbeatsMissed: number; +} + +interface MessageQueueItem { + action: MSA; + args?: { [key: string]: unknown }; + timestamp: number; + retries: number; + id: string; +} class SignalNode extends EventEmitter { - id: string; // client Id; + id: string; + connectionId: string; call: grpc.ServerDuplexStream; metadata: grpc.Metadata; - connectedAt: number; - lastActivity: number; - isActive: boolean; - heartbeatInterval?: NodeJS.Timeout; + private connectionState: ConnectionState; + private metrics: ConnectionMetrics; + private heartbeatInterval?: NodeJS.Timeout; + private messageQueue: MessageQueueItem[] = []; + private maxQueueSize: number = 100; + private messageTimeout: number = 30000; + private heartbeatTimeout: number = 60000; + private maxConsecutiveErrors: number = 5; + private consecutiveErrors: number = 0; + private isShuttingDown: boolean = false; static signalNodes = new Map(); constructor({ id, call, + connectionId, }: { id: string; call: grpc.ServerDuplexStream; + connectionId?: string; }) { super(); + this.id = id; + this.connectionId = connectionId || this.generateConnectionId(); this.call = call; this.metadata = call.metadata; - this.connectedAt = Date.now(); - this.lastActivity = Date.now(); - this.isActive = true; + this.connectionState = ConnectionState.CONNECTING; + + const now = Date.now(); + this.metrics = { + connectedAt: now, + lastActivity: now, + lastHeartbeat: now, + messagesSent: 0, + messagesReceived: 0, + errors: 0, + heartbeatsReceived: 0, + heartbeatsMissed: 0, + }; + + // Add to static collection with duplicate handling + if (SignalNode.signalNodes.has(id)) { + console.warn( + `โš ๏ธ SignalNode with ID ${id} already exists, removing old instance` + ); + const oldNode = SignalNode.signalNodes.get(id); + oldNode?.forceDisconnect('duplicate_connection'); + } SignalNode.signalNodes.set(id, this); - this.handleMessages(); - this.setupHeartbeat(); - console.log('new grpc connection - signalnode connected nodeId', this.id); + + this.initialize(); + + console.log( + `โœ… New SignalNode created - ID: ${this.id}, Connection: ${this.connectionId}` + ); } - static getNodes(): SignalNode[] { - return Array.from(SignalNode.signalNodes.values()); + private initialize(): void { + try { + this.setupMessageHandlers(); + this.setupHeartbeat(); + this.setState(ConnectionState.CONNECTED); + this.sendConnectionConfirmation(); + } catch (error) { + console.error(`โŒ Error initializing SignalNode ${this.id}:`, error); + this.handleError(error as Error, 'initialization_error'); + } + } + + private generateConnectionId(): string { + return `${this.id}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`; + } + + private setState(newState: ConnectionState): void { + if (this.connectionState !== newState) { + const oldState = this.connectionState; + this.connectionState = newState; + console.log(`๐Ÿ“ก SignalNode ${this.id} state: ${oldState} -> ${newState}`); + this.emit('stateChanged', { oldState, newState, nodeId: this.id }); + } } private setupHeartbeat(): void { + this.clearHeartbeat(); + this.heartbeatInterval = setInterval(() => { - if (!this.isActive) { + if (!this.isActive()) { return; } - const timeSinceLastActivity = Date.now() - this.lastActivity; - if (timeSinceLastActivity > 60000) { - console.log(`Connection ${this.id} is stale, removing...`); - this.handleClientDisconnection('stale_connection'); - return; + + const timeSinceLastActivity = Date.now() - this.metrics.lastActivity; + // const timeSinceLastHeartbeat = Date.now() - this.metrics.lastHeartbeat; + + // Check for stale connection + if (timeSinceLastActivity > this.heartbeatTimeout) { + console.warn( + `๐Ÿ’” Connection ${this.id} is stale (${timeSinceLastActivity}ms since last activity)` + ); + this.metrics.heartbeatsMissed++; + + if (this.metrics.heartbeatsMissed >= 3) { + this.handleError(new Error('Heartbeat timeout'), 'heartbeat_timeout'); + return; + } } // Send heartbeat - this.sendMessage(MSA.Heartbeat); + try { + if ( + this.sendMessage(MSA.Heartbeat, { + timestamp: Date.now(), + connectionId: this.connectionId, + }) + ) { + this.metrics.lastHeartbeat = Date.now(); + } + } catch (error) { + console.error(`โŒ Error sending heartbeat to ${this.id}:`, error); + this.handleError(error as Error, 'heartbeat_error'); + } }, 30000); // Send heartbeat every 30 seconds } - private handleMessages(): void { - // Handle incoming messages + private clearHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = undefined; + } + } - this.call.on('data', (message: MessageRequest) => { - const { action, args } = message; - if (!action) return; - const handler = this.actionHandlers[action as MSA]; + private setupMessageHandlers(): void { + if (!this.call) { + throw new Error('gRPC call is null'); + } - if (handler) handler(args && JSON.parse(args)); + // Handle incoming messages + this.call.on('data', (message: MessageRequest) => { + this.handleIncomingMessage(message); }); - // Handle client disconnection + // Handle connection events this.call.on('end', () => { - console.log(`Client ${this.id} ended the connection`); + console.log(`๐Ÿ“ค Client ${this.id} ended the connection gracefully`); this.handleClientDisconnection('client_ended'); }); - // Handle call cancellation this.call.on('cancelled', () => { - console.log(`Client ${this.id} cancelled the connection`); + console.log(`๐Ÿšซ Client ${this.id} cancelled the connection`); this.handleClientDisconnection('cancelled'); }); - this.sendMessage(MSA.Connected, { - status: 'success', + this.call.on('error', (error: Error) => { + console.error(`๐Ÿ’ฅ Stream error for client ${this.id}:`, error); + this.handleError(error, 'stream_error'); + }); + + this.call.on('close', () => { + console.log(`๐Ÿ”Œ Stream closed for client ${this.id}`); + if (this.connectionState === ConnectionState.CONNECTED) { + this.handleClientDisconnection('stream_closed'); + } + }); + } + + private handleIncomingMessage(message: MessageRequest): void { + try { + this.metrics.messagesReceived++; + this.metrics.lastActivity = Date.now(); + this.consecutiveErrors = 0; // Reset error count on successful message + + const { action, args } = message; + + if (!action) { + console.warn(`โš ๏ธ Received message without action from ${this.id}`); + this.handleError( + new Error('Missing action in message'), + 'protocol_error' + ); + return; + } + + console.log(`๐Ÿ“จ Received message from ${this.id}: ${action}`); + + let parsedArgs: { [key: string]: unknown } = {}; + if (args) { + try { + parsedArgs = JSON.parse(args); + } catch (parseError) { + console.error( + `โŒ Failed to parse message args from ${this.id}:`, + parseError + ); + this.handleError(parseError as Error, 'parse_error'); + return; + } + } + + // Handle special system messages + if (action === MSA.Heartbeat) { + this.handleHeartbeat(parsedArgs); + return; + } + + // Find and execute handler + const handler = this.actionHandlers[action as MSA]; + if (handler) { + try { + handler(parsedArgs); + } catch (handlerError) { + console.error( + `โŒ Error in handler for action ${action} from ${this.id}:`, + handlerError + ); + this.handleError(handlerError as Error, 'handler_error'); + } + } else { + console.warn(`โš ๏ธ No handler for action ${action} from ${this.id}`); + this.emit('unhandledMessage', { + nodeId: this.id, + action, + args: parsedArgs, + }); + } + + this.emit('messageReceived', { + nodeId: this.id, + action, + args: parsedArgs, + timestamp: Date.now(), + }); + } catch (error) { + console.error(`๐Ÿ’ฅ Error handling message from ${this.id}:`, error); + this.handleError(error as Error, 'message_handling_error'); + } + } + + private handleHeartbeat(args: { [key: string]: unknown }): void { + console.log(args); + this.metrics.heartbeatsReceived++; + this.metrics.heartbeatsMissed = 0; // Reset missed heartbeats + + // Respond to heartbeat + this.sendMessage(MSA.HeartbeatAck, { + timestamp: Date.now(), + connectionId: this.connectionId, + metrics: { + messagesSent: this.metrics.messagesSent, + messagesReceived: this.metrics.messagesReceived, + uptime: Date.now() - this.metrics.connectedAt, + }, + }); + } + + private handleError(error: Error, context: string): void { + this.metrics.errors++; + this.consecutiveErrors++; + + console.error( + `๐Ÿ’ฅ SignalNode ${this.id} error [${context}]:`, + error.message + ); + + this.emit('error', { nodeId: this.id, - message: 'Successfully connected to Media Signaling Server', + error, + context, + consecutiveErrors: this.consecutiveErrors, + timestamp: Date.now(), }); + + // Disconnect if too many consecutive errors + if (this.consecutiveErrors >= this.maxConsecutiveErrors) { + console.error( + `๐Ÿšซ Too many consecutive errors (${this.consecutiveErrors}) for ${this.id}, disconnecting` + ); + this.handleClientDisconnection('too_many_errors', error); + } } private handleClientDisconnection(reason: string, error?: Error): void { - console.log(`๐Ÿ”Œ Client ${this.id} disconnected (${reason}) - `, error); + if (this.isShuttingDown) { + return; // Already handled + } + + console.log( + `๐Ÿ”Œ Client ${this.id} disconnected (${reason})`, + error ? `: ${error.message}` : '' + ); + + this.setState(ConnectionState.DISCONNECTED); + this.cleanup(); + + this.emit('disconnected', { + nodeId: this.id, + reason, + error, + metrics: this.getMetrics(), + timestamp: Date.now(), + }); + } + + private cleanup(): void { + this.isShuttingDown = true; // Clear heartbeat - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval); - } + this.clearHeartbeat(); + + // Clear message queue + this.messageQueue = []; - // Mark as inactive - this.isActive = false; + // Remove from static collection + SignalNode.signalNodes.delete(this.id); - // Remove from connections - // this.connections.delete(clientId); - // this.stats.activeConnections = Math.max( - // 0, - // this.stats.activeConnections - 1 - // ); + // Remove all listeners + this.removeAllListeners(); + + console.log(`๐Ÿงน Cleaned up SignalNode ${this.id}`); } + private sendConnectionConfirmation(): void { + this.sendMessage(MSA.Connected, { + status: 'success', + nodeId: this.id, + connectionId: this.connectionId, + message: 'Successfully connected to Media Signaling Server', + timestamp: Date.now(), + serverMetrics: grpcServer.getStats() || {}, + }); + } + + // Public methods sendMessage(action: MSA, args?: { [key: string]: unknown }): boolean { - if (!this.isActive) { - console.warn( - `โš ๏ธ Cannot send message to client ${this.id}: signalNode is inactive` - ); + if (!this.isActive()) { + console.warn(`โš ๏ธ Cannot send message to ${this.id}: node is inactive`); + return false; + } + + if (!this.call) { + console.warn(`โš ๏ธ Cannot send message to ${this.id}: call is null`); return false; } + try { - this.call.write({ + const messageId = this.generateMessageId(); + const message = { action, args: JSON.stringify(args || {}), + }; + + this.call.write(message); + + this.metrics.messagesSent++; + this.metrics.lastActivity = Date.now(); + grpcServer.incrementMessageStats(1, 0); + + console.log(`๐Ÿ“ค Sent ${action} to ${this.id} (${messageId})`); + + this.emit('messageSent', { + nodeId: this.id, + action, + args, + messageId, + timestamp: Date.now(), }); - this.lastActivity = Date.now(); + return true; } catch (error) { - console.error(` Error sending message to client ${this.id}:`, error); + console.error(`โŒ Error sending message to ${this.id}:`, error); + this.handleError(error as Error, 'send_message_error'); return false; } } + queueMessage(action: MSA, args?: { [key: string]: unknown }): boolean { + if (this.messageQueue.length >= this.maxQueueSize) { + console.warn( + `โš ๏ธ Message queue full for ${this.id}, dropping oldest message` + ); + this.messageQueue.shift(); // Remove oldest message + } + + const messageItem: MessageQueueItem = { + action, + args, + timestamp: Date.now(), + retries: 0, + id: this.generateMessageId(), + }; + + this.messageQueue.push(messageItem); + console.log( + `๐Ÿ“ Queued message ${action} for ${this.id} (queue size: ${this.messageQueue.length})` + ); + + return true; + } + + flushMessageQueue(): number { + if (!this.isActive() || this.messageQueue.length === 0) { + return 0; + } + + let sentCount = 0; + const messages = [...this.messageQueue]; + this.messageQueue = []; + + messages.forEach(messageItem => { + if (this.sendMessage(messageItem.action, messageItem.args)) { + sentCount++; + } else { + // Re-queue failed messages if under retry limit + if (messageItem.retries < 3) { + messageItem.retries++; + this.messageQueue.push(messageItem); + } + } + }); + + if (sentCount > 0) { + console.log(`๐Ÿ“ค Flushed ${sentCount} queued messages for ${this.id}`); + } + + return sentCount; + } + + async gracefulDisconnect( + reason: string = 'graceful_shutdown' + ): Promise { + if (this.connectionState === ConnectionState.DISCONNECTED) { + return; + } + + console.log(`๐Ÿ‘‹ Gracefully disconnecting ${this.id} (${reason})`); + this.setState(ConnectionState.DISCONNECTING); + + try { + // Send disconnect notification + this.sendMessage(MSA.ServerShutdown, { + message: 'Server is shutting down', + reason, + timestamp: Date.now(), + }); + + // Flush any remaining messages + this.flushMessageQueue(); + + // Wait a bit for messages to be sent + await new Promise(resolve => setTimeout(resolve, 1000)); + + // End the call gracefully + if (this.call) { + this.call.end(); + } + } catch (error) { + console.warn( + `โš ๏ธ Error during graceful disconnect for ${this.id}:`, + error + ); + } finally { + this.handleClientDisconnection(reason); + } + } + + forceDisconnect(reason: string = 'force_disconnect'): void { + console.log(`๐Ÿ’ฅ Force disconnecting ${this.id} (${reason})`); + + try { + if (this.call) { + this.call.destroy(); + } + } catch (error) { + console.warn(`โš ๏ธ Error during force disconnect for ${this.id}:`, error); + } finally { + this.handleClientDisconnection(reason); + } + } + + // Utility methods + isActive(): boolean { + return ( + this.connectionState === ConnectionState.CONNECTED && !this.isShuttingDown + ); + } + + isStale(threshold: number = 300000): boolean { + // 5 minutes default + return Date.now() - this.metrics.lastActivity > threshold; + } + + getMetrics(): ConnectionMetrics { + return { ...this.metrics }; + } + + getState(): ConnectionState { + return this.connectionState; + } + + getUptime(): number { + return Date.now() - this.metrics.connectedAt; + } + + getQueueSize(): number { + return this.messageQueue.length; + } + + private generateMessageId(): string { + return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`; + } + + // Action handlers for different message types private actionHandlers: { [key in MSA]?: (args: { [key: string]: unknown }) => void; } = { - connected: args => { - console.log(args); + [MSA.Connected]: args => { + console.log(`โœ… Connection confirmed from ${this.id}:`, args); + this.emit('connectionConfirmed', { nodeId: this.id, args }); + }, + + [MSA.Heartbeat]: args => { + this.handleHeartbeat(args); + }, + + [MSA.HeartbeatAck]: args => { + console.log(`๐Ÿ’— Heartbeat acknowledged by ${this.id}`, args); + this.metrics.lastActivity = Date.now(); }, + + // Add more handlers as needed for your specific actions }; + + // Static methods for managing all nodes + static getNodes(): SignalNode[] { + return Array.from(SignalNode.signalNodes.values()); + } + + static getNodeById(id: string): SignalNode | undefined { + return SignalNode.signalNodes.get(id); + } + + static getActiveNodes(): SignalNode[] { + return Array.from(SignalNode.signalNodes.values()).filter(node => + node.isActive() + ); + } + + static getNodeCount(): number { + return SignalNode.signalNodes.size; + } + + static getActiveNodeCount(): number { + return this.getActiveNodes().length; + } + + static async disconnectAll(): Promise { + console.log( + `๐Ÿ›‘ Disconnecting all ${SignalNode.signalNodes.size} signal nodes...` + ); + + const nodes = Array.from(SignalNode.signalNodes.values()); + const disconnectPromises = nodes.map(node => + node.gracefulDisconnect('disconnect_all').catch(error => { + console.warn(`โš ๏ธ Error disconnecting node ${node.id}:`, error); + node.forceDisconnect('disconnect_all_force'); + }) + ); + + await Promise.allSettled(disconnectPromises); + SignalNode.signalNodes.clear(); + console.log('โœ… All signal nodes disconnected'); + } + + static broadcastMessage( + action: MSA, + args?: { [key: string]: unknown }, + options?: { + excludeIds?: string[]; + onlyActive?: boolean; + queueIfUnavailable?: boolean; + } + ): { sent: number; queued: number; failed: number } { + const opts = { + excludeIds: [], + onlyActive: true, + queueIfUnavailable: false, + ...options, + }; + + let nodes = Array.from(SignalNode.signalNodes.values()); + + // Filter nodes based on options + if (opts.excludeIds.length > 0) { + nodes = nodes.filter(node => !opts.excludeIds!.includes(node.id)); + } + + if (opts.onlyActive) { + nodes = nodes.filter(node => node.isActive()); + } + + let sent = 0; + let queued = 0; + let failed = 0; + + nodes.forEach(node => { + if (node.isActive()) { + if (node.sendMessage(action, args)) { + sent++; + } else { + failed++; + } + } else if (opts.queueIfUnavailable) { + if (node.queueMessage(action, args)) { + queued++; + } else { + failed++; + } + } else { + failed++; + } + }); + + console.log( + `๐Ÿ“ข Broadcast ${action}: ${sent} sent, ${queued} queued, ${failed} failed` + ); + return { sent, queued, failed }; + } + + static getConnectionStats(): { + total: number; + active: number; + connecting: number; + disconnecting: number; + disconnected: number; + error: number; + } { + const nodes = Array.from(SignalNode.signalNodes.values()); + + return { + total: nodes.length, + active: nodes.filter(n => n.connectionState === ConnectionState.CONNECTED) + .length, + connecting: nodes.filter( + n => n.connectionState === ConnectionState.CONNECTING + ).length, + disconnecting: nodes.filter( + n => n.connectionState === ConnectionState.DISCONNECTING + ).length, + disconnected: nodes.filter( + n => n.connectionState === ConnectionState.DISCONNECTED + ).length, + error: nodes.filter(n => n.connectionState === ConnectionState.ERROR) + .length, + }; + } + + static getDetailedMetrics(): { + totalNodes: number; + activeNodes: number; + totalMessagesSent: number; + totalMessagesReceived: number; + totalErrors: number; + avgUptime: number; + nodes: Array<{ + id: string; + connectionId: string; + state: ConnectionState; + metrics: ConnectionMetrics; + uptime: number; + queueSize: number; + }>; + } { + const nodes = Array.from(SignalNode.signalNodes.values()); + const activeNodes = nodes.filter(n => n.isActive()); + + return { + totalNodes: nodes.length, + activeNodes: activeNodes.length, + totalMessagesSent: nodes.reduce( + (sum, node) => sum + node.metrics.messagesSent, + 0 + ), + totalMessagesReceived: nodes.reduce( + (sum, node) => sum + node.metrics.messagesReceived, + 0 + ), + totalErrors: nodes.reduce((sum, node) => sum + node.metrics.errors, 0), + avgUptime: + nodes.length > 0 + ? nodes.reduce((sum, node) => sum + node.getUptime(), 0) / + nodes.length + : 0, + nodes: nodes.map(node => ({ + id: node.id, + connectionId: node.connectionId, + state: node.connectionState, + metrics: node.getMetrics(), + uptime: node.getUptime(), + queueSize: node.getQueueSize(), + })), + }; + } } export default SignalNode; +export { ConnectionState, SignalNode }; diff --git a/src/types/actions.ts b/src/types/actions.ts index 7d4dc53..1b1e987 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -18,7 +18,37 @@ export enum ServiceActions { } export enum MediaSignalingActions { + // Connection lifecycle Connected = 'connected', + Disconnect = 'disconnect', + Reconnect = 'reconnect', + + // Health monitoring Heartbeat = 'heartbeat', - ServerShutdown = 'server-shutdown', + HeartbeatAck = 'heartbeat_ack', + Ping = 'ping', + Pong = 'pong', + + // Server management + ServerShutdown = 'server_shutdown', + ServerRestart = 'server_restart', + + // Error handling + Error = 'error', + ConnectionError = 'connection_error', + + // Media specific actions (add your custom actions here) + MediaOffer = 'media_offer', + MediaAnswer = 'media_answer', + IceCandidate = 'ice_candidate', + MediaStreamStart = 'media_stream_start', + MediaStreamStop = 'media_stream_stop', + + // Room/channel management + JoinRoom = 'join_room', + LeaveRoom = 'leave_room', + RoomUpdate = 'room_update', + + // Custom actions placeholder + Custom = 'custom', } From 3eaa026124bd66da22bd31bd574a86fe42ceb95e Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Tue, 19 Aug 2025 12:24:28 +0100 Subject: [PATCH 28/31] remove error from room --- src/services/room.ts | 53 +++++--------------------------------------- 1 file changed, 6 insertions(+), 47 deletions(-) diff --git a/src/services/room.ts b/src/services/room.ts index 7bdb40f..031194a 100644 --- a/src/services/room.ts +++ b/src/services/room.ts @@ -6,7 +6,6 @@ import { mediaSoupServer } from '../servers/mediasoup-server'; import { redisServer } from '../servers/redis-server'; import { getRedisKey } from '../lib/utils'; import { ServiceActions } from '../types/actions'; -import { PipeConsumerParams } from '../types'; import MediaNode from './medianode'; class Room extends EventEmitter { @@ -27,18 +26,15 @@ class Room extends EventEmitter { // workers: Map; routers: Map; audioLevelObservers: Map; - routerRtpCapabilities: mediasoupTypes.RtpCapabilities; // all meets in the server private static rooms = new Map(); constructor({ roomId, - // workers, routers, audioLevelObservers, }: { roomId: string; - // workers: Map, routers: Map; audioLevelObservers: Map; }) { @@ -46,16 +42,11 @@ class Room extends EventEmitter { this.roomId = roomId; this.peers = new Map(); - // todo: checking if i should close medianode when meeting closes this.mediaNodes = new Map(); this.closed = false; this.routers = routers; - // this.workers = workers; this.audioLevelObservers = audioLevelObservers; - this.routerRtpCapabilities = Array.from( - routers.values() - )[0].rtpCapabilities; this.activeSpeaker = { peerId: null, timestamp: 0, @@ -171,6 +162,10 @@ class Room extends EventEmitter { return Array.from(this.routers.values()); } + getRouterRtpCapabilities(): mediasoupTypes.RtpCapabilities { + return Array.from(this.routers.values())[0].rtpCapabilities; + } + async assignRouterToPeer(): Promise { const router = this.getLeastLoadedRouter(); if (router) { @@ -369,44 +364,8 @@ class Room extends EventEmitter { consumingMediaNode: MediaNode; }): Promise { try { - const router = this.getLeastLoadedRouter(); - const sendTranport = consumingMediaNode.getSendPipeTransport(router.id); - const pipeConsumer = await sendTranport.consume({ - producerId: producer.id, - }); - consumingMediaNode.addConsumer(pipeConsumer); - - const params: PipeConsumerParams = { - producerId: producer.id, - kind: pipeConsumer.kind, - producerPaused: pipeConsumer.producerPaused, - rtpParameters: pipeConsumer.rtpParameters, - sendTranportId: sendTranport.id, - recvMediaNodeId: consumingMediaNode.id, - sendMediaNodeId: 'config.env.mediaNodeId', - roomId: this.roomId, - producerPeerId, - appData: producer.appData, - }; - /** - * todo - * channel tras - const signal = SignalNode.getASignalNode(); - signal.sendMessage(SIGNALLING_EVENTS.newPipeConsumer, params); - - pipeConsumer.observer.on('close', () => { - signal.sendMessage(SIGNALLING_EVENTS.closePipeConsumer, params); - }); - - pipeConsumer.on('producerpause', () => { - signal.sendMessage(SIGNALLING_EVENTS.pausePipeConsumer, params); - }); - - pipeConsumer.on('producerresume', () => { - signal.sendMessage(SIGNALLING_EVENTS.resumePipeConsumer, params); - }); - console.log('createPipeConsumer'); - */ + // const router = this.getLeastLoadedRouter(); + console.log(producer, producerPeerId, consumingMediaNode); } catch (error) { console.error(`Pipe Consumer failed ${error}`); } From ddfef31c30efc820a2b76142dc72ead0abc3e78e Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Wed, 20 Aug 2025 17:17:42 +0100 Subject: [PATCH 29/31] implement immediate response with grpc --- src/app.ts | 6 +- src/config/index.ts | 3 +- src/lib/utils.ts | 6 +- .../mediaSignalingPackage/MessageRequest.ts | 2 + .../mediaSignalingPackage/MessageResponse.ts | 2 + src/protos/media-signaling.proto | 2 + src/servers/mediasoup-server.ts | 569 ++++++++++++------ src/services/signalnode.ts | 73 ++- src/types/actions.ts | 4 +- 9 files changed, 476 insertions(+), 191 deletions(-) diff --git a/src/app.ts b/src/app.ts index c60eca2..e354a9e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,6 +9,7 @@ import { redisServer } from './servers/redis-server'; import { grpcServer } from './servers/grpc-server'; import { MediaNodeData } from './types'; import { getRedisKey, registerMediaNode } from './lib/utils'; +import { mediaSoupServer } from './servers/mediasoup-server'; const app = express(); app.use(cors(config.cors)); @@ -26,9 +27,12 @@ let medianodeData: MediaNodeData; httpsServer.listen(config.port, () => { console.log(`Server running on port ${config.port}`); }); + await grpcServer.start(); + + await mediaSoupServer.start(); + medianodeData = await registerMediaNode(); console.log('Register medianode'); - await grpcServer.start(); } catch (error) { console.error('Initialization error:', error); process.exit(1); diff --git a/src/config/index.ts b/src/config/index.ts index fd9a786..65d12ea 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -17,7 +17,7 @@ const LISTEN_IP = process.env.LISTEN_IP || '0.0.0.0'; const ANNOUNCED_ADDRESS = process.env.ANNOUNCED_ADDRESS || '127.0.0.1'; const config = { - nodeId: `mnode-${crypto.randomUUID()}`, + nodeId: `mnode-1`, env: process.env.NODE_ENV, cors: { origin: process.env.NODE_ENV === 'production' ? ['https://mitsi.app'] : '*', @@ -37,6 +37,7 @@ const config = { redisServerUrl: process.env.REDIS_SERVER_URL || 'redis://localhost:6379', mediasoup: { + maxWorkerLoad: parseInt(process.env.MAX_WORKER_LOAD || '100'), workerSettings: { dtlsCertificateFile: certFile, dtlsPrivateKeyFile: keyFile, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 98551f0..020d748 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -18,10 +18,10 @@ export const getRedisKey = { export const registerMediaNode = async (): Promise => { try { - const { publicIpv4 } = await import('public-ip'); - const ip = await publicIpv4(); + // const { publicIpv4 } = await import('public-ip'); + // const ip = await publicIpv4(); const medianodeData: MediaNodeData = { - id: ip || config.nodeId, + id: config.nodeId, ip: '0.0.0.0', address: `${config.port}`, grpcPort: `${config.grpcPort}`, diff --git a/src/protos/gen/mediaSignalingPackage/MessageRequest.ts b/src/protos/gen/mediaSignalingPackage/MessageRequest.ts index 4aa01e2..6b94a14 100644 --- a/src/protos/gen/mediaSignalingPackage/MessageRequest.ts +++ b/src/protos/gen/mediaSignalingPackage/MessageRequest.ts @@ -4,9 +4,11 @@ export interface MessageRequest { 'action'?: (string); 'args'?: (string); + 'requestId'?: (string); } export interface MessageRequest__Output { 'action'?: (string); 'args'?: (string); + 'requestId'?: (string); } diff --git a/src/protos/gen/mediaSignalingPackage/MessageResponse.ts b/src/protos/gen/mediaSignalingPackage/MessageResponse.ts index ceb1084..4824732 100644 --- a/src/protos/gen/mediaSignalingPackage/MessageResponse.ts +++ b/src/protos/gen/mediaSignalingPackage/MessageResponse.ts @@ -4,9 +4,11 @@ export interface MessageResponse { 'action'?: (string); 'args'?: (string); + 'requestId'?: (string); } export interface MessageResponse__Output { 'action'?: (string); 'args'?: (string); + 'requestId'?: (string); } diff --git a/src/protos/media-signaling.proto b/src/protos/media-signaling.proto index 088f82a..4d23769 100644 --- a/src/protos/media-signaling.proto +++ b/src/protos/media-signaling.proto @@ -9,9 +9,11 @@ service MediaSignaling { message MessageRequest { string action = 1; string args = 2; + string requestId = 3; } message MessageResponse { string action = 1; string args = 2; + string requestId = 3; } diff --git a/src/servers/mediasoup-server.ts b/src/servers/mediasoup-server.ts index 4607c40..d27e0db 100644 --- a/src/servers/mediasoup-server.ts +++ b/src/servers/mediasoup-server.ts @@ -5,110 +5,267 @@ import { } from 'mediasoup'; import config from '../config'; -class MediasoupServer { - private static instance: MediasoupServer | null; - private workers: Map; - private workerLoads: Map; - private isRunning: boolean; +interface WorkerInfo { + pid: number; + worker: mediasoupTypes.Worker; + load: number; + isHealthy: boolean; + usage?: mediasoupTypes.WorkerResourceUsage; +} + +interface ServerMetrics { + totalWorkers: number; + activeWorkers: number; + totalLoad: number; + averageLoad: number; +} + +export class MediasoupServer { + private static instance: MediasoupServer | null = null; + private workers: Map = new Map(); + private isRunning: boolean = false; + private routerRtpCapabilities: mediasoupTypes.RtpCapabilities | null = null; + private initializationPromise: Promise | null = null; + private readonly maxWorkerLoad: number; private constructor() { - this.workers = new Map(); - this.workerLoads = new Map(); - this.isRunning = false; - this.observe(); - this.createWorkers(); + this.maxWorkerLoad = config.mediasoup.maxWorkerLoad; + this.setupGracefulShutdown(); } static getInstance(): MediasoupServer { - if (!MediasoupServer.instance) + if (!MediasoupServer.instance) { MediasoupServer.instance = new MediasoupServer(); - + } return MediasoupServer.instance; } + async waitForInitialization(): Promise { + if (this.initializationPromise) { + await this.initializationPromise; + } + } + + async start(): Promise { + try { + this.observe(); + await this.createWorkers(); + this.isRunning = true; + + console.info('MediasoupServer started successfully'); + } catch (error) { + console.error('Failed to start MediasoupServer:', error); + throw error; + } + } + private async createWorkers(): Promise { + const workerPromises: Promise[] = []; + const numWorkers = config.cpus; + + for (let i = 0; i < numWorkers; i++) { + workerPromises.push(this.createSingleWorker(i)); + } + + await Promise.all(workerPromises); + console.info(`Created ${numWorkers} mediasoup workers`); + } + + private async createSingleWorker(index: number): Promise { try { - if (this.isRunning) { - return console.log('Mediasoup Workers is already created'); - } + const worker = await createWorker({ + ...config.mediasoup.workerSettings, + appData: { index }, + }); - for (let i = 0; i < config.cpus; i++) { - const worker = await createWorker(config.mediasoup.workerSettings); - worker.createWebRtcServer(config.mediasoup.webRtcServer); - worker.once('died', () => { - console.error('Worker died', { workerId: worker.pid }); - setTimeout(() => process.exit(1), 2000); - }); + // Create WebRTC server for this worker + await worker.createWebRtcServer(config.mediasoup.webRtcServer); - this.workers.set(worker.pid, worker); - this.workerLoads.set(worker.pid, 0); - } + this.setupWorkerEventHandlers(worker); + + const workerInfo: WorkerInfo = { + pid: worker.pid, + worker, + load: 0, + isHealthy: true, + }; + + this.workers.set(worker.pid, workerInfo); + console.info(`Worker ${worker.pid} created successfully`); } catch (error) { - console.error('Worker start error! \n', error); - process.exit(1); + console.error(`Failed to create worker ${index}:`, error); + throw error; } } - increaseWorkerLoad(workerPid: number): void { - if (!this.isRunning) { - console.log('SerStart Mediasoup worker first'); - return; + private setupWorkerEventHandlers(worker: mediasoupTypes.Worker): void { + worker.once('died', () => { + console.error(`Worker ${worker.pid} died unexpectedly`); + // this.handleWorkerDeath(worker.pid); + }); + + worker.on('subprocessclose', () => { + console.warn(`Worker ${worker.pid} subprocess closed`); + }); + + // Handle resource usage monitoring + worker.observer.on('close', () => { + console.info(`Worker ${worker.pid} closed`); + this.workers.delete(worker.pid); + }); + } + + increaseWorkerLoad(workerPid: number): boolean { + this.ensureRunning(); + + const workerInfo = this.workers.get(workerPid); + if (!workerInfo) { + console.warn(`Worker ${workerPid} not found`); + return false; } - if (this.workerLoads.get(workerPid)) { - this.workerLoads.set( - workerPid, - (this.workerLoads.get(workerPid) as number) + 1 - ); + + if (workerInfo.load >= this.maxWorkerLoad) { + console.warn(`Worker ${workerPid} is at maximum load`); + return false; } + + workerInfo.load += 1; + return true; } - decreaseWorkerLoad(workerPid: number): void { - if (!this.isRunning) { - console.log('Start Mediasoup worker first'); - return; - } - if (this.workerLoads.get(workerPid)) { - this.workerLoads.set( - workerPid, - (this.workerLoads.get(workerPid) as number) - 1 - ); + decreaseWorkerLoad(workerPid: number): boolean { + this.ensureRunning(); + + const workerInfo = this.workers.get(workerPid); + if (!workerInfo) { + console.warn(`Worker ${workerPid} not found`); + return false; } + + workerInfo.load = Math.max(0, workerInfo.load - 1); + return true; } - getWorkers(): mediasoupTypes.Worker[] { - return Array.from(this.workers.values()); + getHealthyWorkers(): WorkerInfo[] { + return Array.from(this.workers.values()).filter(info => info.isHealthy); } - getLeastLoadedWorker(): mediasoupTypes.Worker | undefined { - const sortedWorkerLoads = new Map( - [...this.workerLoads.entries()].sort((a, b) => a[1] - b[1]) + getLeastLoadedWorker(): mediasoupTypes.Worker | null { + const healthyWorkers = this.getHealthyWorkers(); + + if (healthyWorkers.length === 0) { + console.error('No healthy workers available'); + return null; + } + + const leastLoaded = healthyWorkers.reduce((min, current) => + current.load < min.load ? current : min ); - const workerId = sortedWorkerLoads.keys().next().value; - if (!workerId) return; - return this.workers.get(workerId); + + return leastLoaded.worker; + } + + async getWorkerWithCapacity(): Promise { + const worker = this.getLeastLoadedWorker(); + if (!worker) return null; + + const workerInfo = this.workers.get(worker.pid); + if (!workerInfo || workerInfo.load >= this.maxWorkerLoad) { + console.warn('All workers are at capacity'); + return null; + } + + return worker; } async getRouterRtpCapabilities(): Promise { + this.ensureRunning(); + + if (this.routerRtpCapabilities) { + return this.routerRtpCapabilities; + } + try { - if (!this.isRunning) { - throw 'Start Mediasoup worker first'; + const worker = this.getLeastLoadedWorker(); + if (!worker) { + throw new Error('No healthy workers available for router creation'); } - const worker = Array.from(this.workers.values())[0]; const router = await worker.createRouter({ mediaCodecs: config.mediasoup.routerMediaCodecs, }); - const routerRtpCapabilities = router.rtpCapabilities; + + this.routerRtpCapabilities = router.rtpCapabilities; + + // Close the temporary router router.close(); - return routerRtpCapabilities; + + return this.routerRtpCapabilities; } catch (error) { - console.error('getRouterRtpCapabilities error', { error }); + console.error('Failed to get router RTP capabilities:', error); throw error; } } + getServerMetrics(): ServerMetrics { + const workerInfos = Array.from(this.workers.values()); + const activeWorkers = workerInfos.filter(info => info.isHealthy); + const totalLoad = activeWorkers.reduce((sum, info) => sum + info.load, 0); + + return { + totalWorkers: workerInfos.length, + activeWorkers: activeWorkers.length, + totalLoad, + averageLoad: + activeWorkers.length > 0 ? totalLoad / activeWorkers.length : 0, + }; + } + + gracefulShutdown(): void { + console.info('Starting graceful shutdown...'); + this.isRunning = false; + + Array.from(this.workers.values()).map(workerInfo => { + try { + workerInfo.worker.close(); + } catch (error) { + console.error(`Error closing worker ${workerInfo.worker.pid}:`, error); + } + }); + + this.workers.clear(); + console.info('Graceful shutdown completed'); + } + + private ensureRunning(): void { + if (!this.isRunning) { + throw new Error( + 'MediasoupServer is not running. Call waitForInitialization() first.' + ); + } + } + + private setupGracefulShutdown(): void { + const shutdown = (): void => { + this.gracefulShutdown(); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + process.on('uncaughtException', error => { + console.error('Uncaught exception:', error); + shutdown(); + }); + process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled rejection at:', promise, 'reason:', reason); + shutdown(); + }); + } + private observe(): void { mediasoupObserver.on('newworker', worker => { + // Initialize worker app data worker.appData.routers = new Map(); worker.appData.transports = new Map(); worker.appData.producers = new Map(); @@ -116,121 +273,189 @@ class MediasoupServer { worker.appData.dataProducers = new Map(); worker.appData.dataConsumers = new Map(); worker.appData.webRtcServer = null; - worker.appData.load = 0; - worker.observer.on('close', () => { - console.info('Worker closed'); - }); - worker.observer.on('newwebrtcserver', webRtcServer => { - console.log('newwebrtcserver created'); - worker.appData.webRtcServer = webRtcServer; - }); - worker.observer.on('newrouter', router => { - router.appData.transports = new Map(); - router.appData.producers = new Map(); - router.appData.consumers = new Map(); - router.appData.dataProducers = new Map(); - router.appData.dataConsumers = new Map(); - router.appData.webRtcServer = worker.appData.webRtcServer; - router.appData.worker = worker; - (worker.appData.routers as Map).set( - router.id, - router - ); - - router.observer.on('close', () => { - (worker.appData.routers as Map).delete( - router.id - ); - }); - router.observer.on('newtransport', transport => { - transport.appData.producers = new Map(); - transport.appData.consumers = new Map(); - transport.appData.dataProducers = new Map(); - transport.appData.dataConsumers = new Map(); - - transport.appData.router = router; - ( - router.appData.transports as Map - ).set(transport.id, transport); - ( - worker.appData.transports as Map - ).set(transport.id, transport); - - transport.observer.on('close', () => { - ( - router.appData.transports as Map - ).delete(transport.id); - ( - worker.appData.transports as Map - ).delete(transport.id); - }); - - transport.observer.on('newproducer', producer => { - producer.appData.transport = producer; - ( - transport.appData.producers as Map< - string, - mediasoupTypes.Producer - > - ).set(producer.id, producer); - ( - router.appData.producers as Map - ).set(producer.id, producer); - ( - worker.appData.producers as Map - ).set(producer.id, producer); - - producer.observer.on('close', () => { - ( - transport.appData.producers as Map< - string, - mediasoupTypes.Producer - > - ).delete(producer.id); - ( - router.appData.producers as Map - ).delete(producer.id); - ( - worker.appData.producers as Map - ).delete(producer.id); - }); - }); - - transport.observer.on('newconsumer', consumer => { - consumer.appData.transport = consumer; - ( - transport.appData.consumers as Map< - string, - mediasoupTypes.Consumer - > - ).set(consumer.id, consumer); - ( - router.appData.consumers as Map - ).set(consumer.id, consumer); - ( - worker.appData.consumers as Map - ).set(consumer.id, consumer); - - consumer.observer.on('close', () => { - ( - transport.appData.consumers as Map< - string, - mediasoupTypes.Consumer - > - ).delete(consumer.id); - ( - router.appData.consumers as Map - ).delete(consumer.id); - ( - worker.appData.consumers as Map - ).delete(consumer.id); - }); - }); - }); - }); + // Set up observer chain for resource tracking + this.setupWorkerObservers(worker); + }); + } + + private setupWorkerObservers(worker: mediasoupTypes.Worker): void { + worker.observer.on('close', () => { + console.info(`Worker ${worker.pid} observer closed`); + }); + + worker.observer.on('newwebrtcserver', webRtcServer => { + console.info(`WebRTC server created for worker ${worker.pid}`); + worker.appData.webRtcServer = webRtcServer; + }); + + worker.observer.on('newrouter', router => { + this.setupRouterObservers(router, worker); + }); + } + + private setupRouterObservers( + router: mediasoupTypes.Router, + worker: mediasoupTypes.Worker + ): void { + // Initialize router app data + router.appData.transports = new Map(); + router.appData.producers = new Map(); + router.appData.consumers = new Map(); + router.appData.dataProducers = new Map(); + router.appData.dataConsumers = new Map(); + router.appData.webRtcServer = worker.appData.webRtcServer; + router.appData.worker = worker; + + // Add router to worker's collection + (worker.appData.routers as Map).set( + router.id, + router + ); + + router.observer.on('close', () => { + (worker.appData.routers as Map).delete( + router.id + ); + }); + + router.observer.on('newtransport', transport => { + this.setupTransportObservers(transport, router, worker); + }); + } + + private setupTransportObservers( + transport: mediasoupTypes.Transport, + router: mediasoupTypes.Router, + worker: mediasoupTypes.Worker + ): void { + // Initialize transport app data + transport.appData.producers = new Map(); + transport.appData.consumers = new Map(); + transport.appData.dataProducers = new Map(); + transport.appData.dataConsumers = new Map(); + transport.appData.router = router; + + // Add transport to collections + (router.appData.transports as Map).set( + transport.id, + transport + ); + (worker.appData.transports as Map).set( + transport.id, + transport + ); + + transport.observer.on('close', () => { + ( + router.appData.transports as Map + ).delete(transport.id); + ( + worker.appData.transports as Map + ).delete(transport.id); + }); + + transport.observer.on('newproducer', producer => { + this.setupProducerObservers(producer, transport, router, worker); + }); + + transport.observer.on('newconsumer', consumer => { + this.setupConsumerObservers(consumer, transport, router, worker); + }); + } + + private setupProducerObservers( + producer: mediasoupTypes.Producer, + transport: mediasoupTypes.Transport, + router: mediasoupTypes.Router, + worker: mediasoupTypes.Worker + ): void { + producer.appData.transport = transport; + + // Add producer to collections + (transport.appData.producers as Map).set( + producer.id, + producer + ); + (router.appData.producers as Map).set( + producer.id, + producer + ); + (worker.appData.producers as Map).set( + producer.id, + producer + ); + + producer.observer.on('close', () => { + ( + transport.appData.producers as Map + ).delete(producer.id); + (router.appData.producers as Map).delete( + producer.id + ); + (worker.appData.producers as Map).delete( + producer.id + ); }); } + + private setupConsumerObservers( + consumer: mediasoupTypes.Consumer, + transport: mediasoupTypes.Transport, + router: mediasoupTypes.Router, + worker: mediasoupTypes.Worker + ): void { + consumer.appData.transport = transport; + + // Add consumer to collections + (transport.appData.consumers as Map).set( + consumer.id, + consumer + ); + (router.appData.consumers as Map).set( + consumer.id, + consumer + ); + (worker.appData.consumers as Map).set( + consumer.id, + consumer + ); + + consumer.observer.on('close', () => { + ( + transport.appData.consumers as Map + ).delete(consumer.id); + (router.appData.consumers as Map).delete( + consumer.id + ); + (worker.appData.consumers as Map).delete( + consumer.id + ); + }); + } + + // Utility methods for debugging and monitoring + + async getWorkerResourceUsage(workerPid: number): Promise { + const workerInfo = this.workers.get(workerPid); + if (!workerInfo) return null; + return { + ...workerInfo, + usage: await workerInfo.worker.getResourceUsage(), + }; + } + + async getWorkersResourceUsage(): Promise { + const usagePromises = Array.from(this.workers.values()).map( + async workerInfo => ({ + ...workerInfo, + usage: await workerInfo.worker.getResourceUsage(), + }) + ); + + return await Promise.all(usagePromises); + } } +// Export singleton instance export const mediaSoupServer = MediasoupServer.getInstance(); diff --git a/src/services/signalnode.ts b/src/services/signalnode.ts index 94fa675..b108ecb 100644 --- a/src/services/signalnode.ts +++ b/src/services/signalnode.ts @@ -5,6 +5,7 @@ import { MediaSignalingActions as MSA } from '../types/actions'; import { MessageRequest } from '../protos/gen/mediaSignalingPackage/MessageRequest'; import { MessageResponse } from '../protos/gen/mediaSignalingPackage/MessageResponse'; import { grpcServer } from '../servers/grpc-server'; +import { mediaSoupServer } from '../servers/mediasoup-server'; enum ConnectionState { CONNECTING = 'CONNECTING', @@ -48,6 +49,7 @@ class SignalNode extends EventEmitter { private maxConsecutiveErrors: number = 5; private consecutiveErrors: number = 0; private isShuttingDown: boolean = false; + private pendingRequests: Map void>; static signalNodes = new Map(); @@ -67,6 +69,7 @@ class SignalNode extends EventEmitter { this.call = call; this.metadata = call.metadata; this.connectionState = ConnectionState.CONNECTING; + this.pendingRequests = new Map(); const now = Date.now(); this.metrics = { @@ -211,7 +214,18 @@ class SignalNode extends EventEmitter { this.metrics.lastActivity = Date.now(); this.consecutiveErrors = 0; // Reset error count on successful message - const { action, args } = message; + const { action, args, requestId } = message; + + if (requestId?.length) { + const resolver = this.pendingRequests.get(requestId); + if (resolver) { + // this means this instance initiated this request for response . + // resolve and return + resolver(message); + this.pendingRequests.delete(requestId); + return; + } + } if (!action) { console.warn(`โš ๏ธ Received message without action from ${this.id}`); @@ -360,7 +374,8 @@ class SignalNode extends EventEmitter { console.log(`๐Ÿงน Cleaned up SignalNode ${this.id}`); } - private sendConnectionConfirmation(): void { + private async sendConnectionConfirmation(): Promise { + const rtpCapabilities = await mediaSoupServer.getRouterRtpCapabilities(); this.sendMessage(MSA.Connected, { status: 'success', nodeId: this.id, @@ -368,6 +383,7 @@ class SignalNode extends EventEmitter { message: 'Successfully connected to Media Signaling Server', timestamp: Date.now(), serverMetrics: grpcServer.getStats() || {}, + routerRtpCapabilities: rtpCapabilities, }); } @@ -398,14 +414,6 @@ class SignalNode extends EventEmitter { console.log(`๐Ÿ“ค Sent ${action} to ${this.id} (${messageId})`); - this.emit('messageSent', { - nodeId: this.id, - action, - args, - messageId, - timestamp: Date.now(), - }); - return true; } catch (error) { console.error(`โŒ Error sending message to ${this.id}:`, error); @@ -414,6 +422,37 @@ class SignalNode extends EventEmitter { } } + async sendMessageForResponse( + action: MSA, + args?: { [key: string]: unknown } + ): Promise { + if (!this.call) { + console.warn( + `โš ๏ธ Cannot send message to MediaNode ${this.id}: not connected` + ); + return null; + } + + try { + const requestId = crypto.randomUUID(); + const message: MessageRequest = { + action, + args: JSON.stringify(args || {}), + requestId, + }; + + return new Promise(resolve => { + if (this.call) { + this.pendingRequests.set(requestId, resolve); // save resolve + this.call.write(message); + } + }); + } catch (error) { + console.error(`โŒ Error sending message to MediaNode ${this.id}:`, error); + throw error; + } + } + queueMessage(action: MSA, args?: { [key: string]: unknown }): boolean { if (this.messageQueue.length >= this.maxQueueSize) { console.warn( @@ -552,15 +591,23 @@ class SignalNode extends EventEmitter { // Action handlers for different message types private actionHandlers: { - [key in MSA]?: (args: { [key: string]: unknown }) => void; + [key in MSA]?: ( + args: { [key: string]: unknown }, + requestId?: string + ) => void; } = { [MSA.Connected]: args => { console.log(`โœ… Connection confirmed from ${this.id}:`, args); this.emit('connectionConfirmed', { nodeId: this.id, args }); }, - [MSA.Heartbeat]: args => { - this.handleHeartbeat(args); + [MSA.Ping]: (args, requestId) => { + console.log('Signal Server Pinged Mediaserver'); + this.call.write({ + action: MSA.Pong, + args: JSON.stringify(args), + requestId, + }); }, [MSA.HeartbeatAck]: args => { diff --git a/src/types/actions.ts b/src/types/actions.ts index 1b1e987..343cea6 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -29,7 +29,7 @@ export enum MediaSignalingActions { Ping = 'ping', Pong = 'pong', - // Server management + // Server management` ServerShutdown = 'server_shutdown', ServerRestart = 'server_restart', @@ -51,4 +51,6 @@ export enum MediaSignalingActions { // Custom actions placeholder Custom = 'custom', + + RtpCapabilities = 'rtp_capabilities', } From e239c9e7acfaffb658bb4e233e9d1140ffb57b6d Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Thu, 21 Aug 2025 18:05:42 +0100 Subject: [PATCH 30/31] fix error in room --- src/services/room.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/room.ts b/src/services/room.ts index 031194a..8330247 100644 --- a/src/services/room.ts +++ b/src/services/room.ts @@ -93,8 +93,8 @@ class Room extends EventEmitter { mediasoupTypes.AudioLevelObserver > = new Map(); - for (const worker of mediaSoupServer.getWorkers()) { - const router = await worker.createRouter({ + for (const workerInfo of mediaSoupServer.getHealthyWorkers()) { + const router = await workerInfo.worker.createRouter({ mediaCodecs: config.mediasoup.routerMediaCodecs, }); routers.set(router.id, router); From f430ec3fa4d5edbddf6fd9d84ca3e6e28e8ec335 Mon Sep 17 00:00:00 2001 From: obrucheoghene Date: Thu, 21 Aug 2025 18:39:56 +0100 Subject: [PATCH 31/31] fix build error --- src/services/medianode.ts | 7 +++---- src/services/room.ts | 3 ++- src/types/index.ts | 2 ++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/services/medianode.ts b/src/services/medianode.ts index 950ce82..e8dab4f 100644 --- a/src/services/medianode.ts +++ b/src/services/medianode.ts @@ -1,11 +1,9 @@ import { EventEmitter } from 'events'; import { types as mediasoupTypes } from 'mediasoup'; import Room from './room'; -import { TransportConnectionParams } from '../types'; +import { AppDataWithRouterId, TransportConnectionParams } from '../types'; import config from '../config'; -type AppDataWithRouterId = mediasoupTypes.AppData & { routerId: string }; - class MediaNode extends EventEmitter { id: string; roomId: string; @@ -81,11 +79,12 @@ class MediaNode extends EventEmitter { mediasoupTypes.PipeTransport >(); for (const transport of sendPipeTransports) { - const routerId = transport.appData.routerId; + const routerId = transport.appData.routerId as string; sendPipeTansportsMap.set(routerId, transport); } const recvRouter = room.getLeastLoadedRouter(); + if (!recvRouter) throw 'No router found'; // const recvPipeTransport = await meeting.createPipeTransport({ router: recvRouter }); const mediaNode = new MediaNode({ diff --git a/src/services/room.ts b/src/services/room.ts index 8330247..7580efb 100644 --- a/src/services/room.ts +++ b/src/services/room.ts @@ -7,6 +7,7 @@ import { redisServer } from '../servers/redis-server'; import { getRedisKey } from '../lib/utils'; import { ServiceActions } from '../types/actions'; import MediaNode from './medianode'; +import { AppDataWithRouterId } from '../types'; class Room extends EventEmitter { roomId: string; @@ -322,7 +323,7 @@ class Room extends EventEmitter { router, }: { router: mediasoupTypes.Router; - }): Promise { + }): Promise> { const pipeTransport = await router.createPipeTransport({ listenInfo: config.mediasoup.transportListenInfo, enableSctp: true, diff --git a/src/types/index.ts b/src/types/index.ts index cc5f93c..e1effb1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -155,4 +155,6 @@ export interface TransportConnectionParams { srtpParameters?: mediasoupTypes.SrtpParameters; } +export type AppDataWithRouterId = mediasoupTypes.AppData & { routerId: string }; + // WorkerData, RouterData, TransportData, ConsumerData, Producer