require('dotenv').config() const express = require('express'); const app = express(); const Server = require('socket.io'); const path = require('node:path'); const fs = require('node:fs'); let https; try { https = require('node:https'); } catch (err) { console.log('https support is disabled!'); } const mediasoup = require('mediasoup'); let worker /** * videoCalls * |-> Router * |-> Producer * |-> Consumer * |-> Producer Transport * |-> Consumer Transport * * '': { * router: Router, * producer: Producer, * producerTransport: Producer Transport, * consumer: Consumer, * consumerTransport: Consumer Transport * } * **/ let videoCalls = {} let socketDetails = {} app.get('/', (_req, res) => { res.send('Hello from mediasoup app!') }) app.use('/sfu', express.static(path.join(__dirname, 'public'))) // SSL cert for HTTPS access const options = { key: fs.readFileSync(process.env.SERVER_KEY, 'utf-8'), cert: fs.readFileSync(process.env.SERVER_CERT, 'utf-8'), } const httpsServer = https.createServer(options, app); const io = new Server(httpsServer, { allowEIO3: true, origins: ["*:*"], // allowRequest: (req, next) => { // console.log('req', req); // next(null, true) // } }); // const io = new Server(server, { origins: '*:*', allowEIO3: true }); httpsServer.listen(process.env.PORT, () => { console.log('Video server listening on port:', process.env.PORT); }); const peers = io.of('/'); const createWorker = async () => { try { worker = await mediasoup.createWorker({ rtcMinPort: parseInt(process.env.RTC_MIN_PORT), rtcMaxPort: parseInt(process.env.RTC_MAX_PORT), }) console.log(`[createWorker] worker pid ${worker.pid}`); worker.on('died', error => { // This implies something serious happened, so kill the application console.error('mediasoup worker has died', error); setTimeout(() => process.exit(1), 2000); // exit in 2 seconds }) return worker; } catch (error) { console.log(`ERROR | createWorker | ${error.message}`); } } // We create a Worker as soon as our application starts worker = createWorker(); // This is an Array of RtpCapabilities // https://mediasoup.org/documentation/v3/mediasoup/rtp-parameters-and-capabilities/#RtpCodecCapability // list of media codecs supported by mediasoup ... // https://github.com/versatica/mediasoup/blob/v3/src/supportedRtpCapabilities.ts const mediaCodecs = [ { kind : 'audio', mimeType : 'audio/opus', clockRate : 48000, channels : 2 }, { kind : 'video', mimeType : 'video/VP8', clockRate : 90000, parameters : { 'x-google-start-bitrate' : 1000 }, channels : 2 }, { 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 } } // { // kind: 'audio', // mimeType: 'audio/opus', // clockRate: 48000, // channels: 2, // }, // { // kind: 'video', // mimeType: 'video/VP8', // clockRate: 90000, // parameters: { // 'x-google-start-bitrate': 1000, // }, // }, ]; const closeCall = (callId) => { try { if (callId && videoCalls[callId]) { videoCalls[callId].producerVideo?.close(); videoCalls[callId].producerAudio?.close(); videoCalls[callId].consumerVideo?.close(); videoCalls[callId].consumerAudio?.close(); videoCalls[callId]?.consumerTransport?.close(); videoCalls[callId]?.producerTransport?.close(); videoCalls[callId]?.router?.close(); delete videoCalls[callId]; } else { console.log(`The call with id ${callId} has already been deleted`); } } catch (error) { console.log(`ERROR | closeCall | callid ${callId} | ${error.message}`); } } /* - Handlers for WS events - These are created only when we have a connection with a peer */ peers.on('connection', async socket => { console.log('[connection] socketId:', socket.id); // After making the connection successfully, we send the client a 'connection-success' event socket.emit('connection-success', { socketId: socket.id }); // It is triggered when the peer is disconnected socket.on('disconnect', () => { const callId = socketDetails[socket.id]; console.log(`disconnect | socket ${socket.id} | callId ${callId}`); delete socketDetails[socket.id]; closeCall(callId); }); /* - This event creates a room with the roomId and the callId sent - It will return the rtpCapabilities of that room - If the room already exists, it will not create it, but will only return rtpCapabilities */ socket.on('createRoom', async ({ callId }, callback) => { let callbackResponse = null; try { // We can continue with the room creation process only if we have a callId if (callId) { console.log(`[createRoom] socket.id ${socket.id} callId ${callId}`); if (!videoCalls[callId]) { console.log('[createRoom] callId', callId); videoCalls[callId] = { router: await worker.createRouter({ mediaCodecs }) } console.log(`[createRoom] Router ID: ${videoCalls[callId].router.id}`); } socketDetails[socket.id] = callId; // rtpCapabilities is set for callback console.log('[getRtpCapabilities] callId', callId); callbackResponse = { rtpCapabilities :videoCalls[callId].router.rtpCapabilities }; } else { console.log(`[createRoom] missing callId ${callId}`); } } catch (error) { console.log(`ERROR | createRoom | callId ${callId} | ${error.message}`); } finally { callback(callbackResponse); } }); /* - Client emits a request to create server side Transport - Depending on the sender, producerTransport or consumerTransport is created on that router - It will return parameters, these are required for the client to create the RecvTransport from the client. - If the client is producer(sender: true) then it will use parameters for device.createSendTransport(params) - If the client is a consumer(sender: false) then it will use parameters for device.createRecvTransport(params) */ socket.on('createWebRtcTransport', async ({ sender }, callback) => { try { const callId = socketDetails[socket.id]; console.log(`[createWebRtcTransport] sender ${sender} | callId ${callId}`); if (sender) { if (!videoCalls[callId].producerTransport) { videoCalls[callId].producerTransport = await createWebRtcTransportLayer(callId, callback); } else { console.log(`producerTransport has already been defined | callId ${callId}`); callback(null); } } else if (!sender) { if (!videoCalls[callId].consumerTransport) { videoCalls[callId].consumerTransport = await createWebRtcTransportLayer(callId, callback); } else { console.log(`consumerTransport has already been defined | callId ${callId}`); callback(null); } } } catch (error) { console.log(`ERROR | createWebRtcTransport | callId ${socketDetails[socket.id]} | sender ${sender} | ${error.message}`); callback(error); } }); /* - The client sends this event after successfully creating a createSendTransport(AS PRODUCER) - The connection is made to the created transport */ socket.on('transport-connect', async ({ dtlsParameters }) => { try { const callId = socketDetails[socket.id]; if (typeof dtlsParameters === 'string') dtlsParameters = JSON.parse(dtlsParameters); console.log(`[transport-connect] socket.id ${socket.id} | callId ${callId}`); await videoCalls[callId].producerTransport.connect({ dtlsParameters }); } catch (error) { console.log(`ERROR | transport-connect | callId ${socketDetails[socket.id]} | ${error.message}`); } }); /* - The event sent by the client (PRODUCER) after successfully connecting to producerTransport - For the router with the id callId, we make produce on producerTransport - Create the handler on producer at the 'transportclose' event */ socket.on('transport-produce', async ({ kind, rtpParameters, appData }, callback) => { try { const callId = socketDetails[socket.id]; if (typeof rtpParameters === 'string') rtpParameters = JSON.parse(rtpParameters); console.log(`[transport-produce] kind: ${kind} | socket.id: ${socket.id} | callId: ${callId}`); console.log('kind', kind); console.log('rtpParameters', rtpParameters); if (kind === 'video') { videoCalls[callId].producerVideo = await videoCalls[callId].producerTransport.produce({ kind, rtpParameters, }); console.log(`[transport-produce] Producer ID: ${videoCalls[callId].producerVideo.id} | kind: ${videoCalls[callId].producerVideo.kind}`); videoCalls[callId].producerVideo.on('transportclose', () => { const callId = socketDetails[socket.id]; console.log('transport for this producer closed', callId) closeCall(callId); }); // Send back to the client the Producer's id callback && callback({ id: videoCalls[callId].producerVideo.id }); } else if (kind === 'audio') { videoCalls[callId].producerAudio = await videoCalls[callId].producerTransport.produce({ kind, rtpParameters, }); console.log(`[transport-produce] Producer ID: ${videoCalls[callId].producerAudio.id} | kind: ${videoCalls[callId].producerAudio.kind}`); videoCalls[callId].producerAudio.on('transportclose', () => { const callId = socketDetails[socket.id]; console.log('transport for this producer closed', callId) closeCall(callId); }); // Send back to the client the Producer's id callback && callback({ id: videoCalls[callId].producerAudio.id }); } } catch (error) { console.log(`ERROR | transport-produce | callId ${socketDetails[socket.id]} | ${error.message}`); } }); /* - The client sends this event after successfully creating a createRecvTransport(AS CONSUMER) - The connection is made to the created consumerTransport */ socket.on('transport-recv-connect', async ({ dtlsParameters }) => { try { const callId = socketDetails[socket.id]; console.log(`[transport-recv-connect] socket.id ${socket.id} | callId ${callId}`); await videoCalls[callId].consumerTransport.connect({ dtlsParameters }); } catch (error) { console.log(`ERROR | transport-recv-connect | callId ${socketDetails[socket.id]} | ${error.message}`); } }) /* - The customer consumes after successfully connecting to consumerTransport - The previous step was 'transport-recv-connect', and before that 'createWebRtcTransport' - This event is only sent by the consumer - The parameters that the consumer consumes are returned - The consumer does consumerTransport.consume(params) */ socket.on('consume', async ({ rtpCapabilities }, callback) => { try { console.log(`[consume] rtpCapabilities: ${JSON.stringify(rtpCapabilities)}`); const callId = socketDetails[socket.id]; console.log('[consume] callId', callId); const canConsumeVideo = !!videoCalls[callId].producerVideo && !!videoCalls[callId].router.canConsume({ producerId: videoCalls[callId].producerVideo.id, rtpCapabilities }) const canConsumeAudio = !!videoCalls[callId].producerAudio && !!videoCalls[callId].router.canConsume({ producerId: videoCalls[callId].producerAudio.id, rtpCapabilities }) console.log('[consume] canConsumeVideo', canConsumeVideo); console.log('[consume] canConsumeAudio', canConsumeAudio); if (canConsumeVideo && !canConsumeAudio) { console.log('1'); const videoParams = await consumeVideo(callId, rtpCapabilities) console.log('videoParams', videoParams); callback({ videoParams, audioParams: null }); } else if (canConsumeVideo && canConsumeAudio) { console.log('2'); const videoParams = await consumeVideo(callId, rtpCapabilities) const audioParams = await consumeAudio(callId, rtpCapabilities) callback({ videoParams, audioParams }); } else { console.log(`[consume] Can't consume | callId ${callId}`); callback(null); } } catch (error) { console.log(`ERROR | consume | callId ${socketDetails[socket.id]} | ${error.message}`) callback({ params: { error } }); } }); /* - Event sent by the consumer after consuming to resume the pause - When consuming on consumerTransport, it is initially done with paused: true, here we will resume */ socket.on('consumer-resume', async () => { try { const callId = socketDetails[socket.id]; console.log(`[consumer-resume] callId ${callId}`) await videoCalls[callId].consumerVideo.resume(); await videoCalls[callId].consumerAudio.resume(); } catch (error) { console.log(`ERROR | consumer-resume | callId ${socketDetails[socket.id]} | ${error.message}`); } }); }); const consumeVideo = async (callId, rtpCapabilities) => { videoCalls[callId].consumerVideo = await videoCalls[callId].consumerTransport.consume({ producerId: videoCalls[callId].producerVideo.id, rtpCapabilities, paused: true, }); // https://mediasoup.org/documentation/v3/mediasoup/api/#consumer-on-transportclose videoCalls[callId].consumerVideo.on('transportclose', () => { const callId = socketDetails[socket.id]; console.log('transport close from consumer', callId); closeCall(callId); }); // https://mediasoup.org/documentation/v3/mediasoup/api/#consumer-on-producerclose videoCalls[callId].consumerVideo.on('producerclose', () => { const callId = socketDetails[socket.id]; console.log('producer of consumer closed', callId); closeCall(callId); }); return { id: videoCalls[callId].consumerVideo.id, producerId: videoCalls[callId].producerVideo.id, kind: 'video', rtpParameters: videoCalls[callId].consumerVideo.rtpParameters, } } const consumeAudio = async (callId, rtpCapabilities) => { videoCalls[callId].consumerAudio = await videoCalls[callId].consumerTransport.consume({ producerId: videoCalls[callId].producerAudio.id, rtpCapabilities, paused: true, }); // https://mediasoup.org/documentation/v3/mediasoup/api/#consumer-on-transportclose videoCalls[callId].consumerAudio.on('transportclose', () => { const callId = socketDetails[socket.id]; console.log('transport close from consumer', callId); closeCall(callId); }); // https://mediasoup.org/documentation/v3/mediasoup/api/#consumer-on-producerclose videoCalls[callId].consumerAudio.on('producerclose', () => { const callId = socketDetails[socket.id]; console.log('producer of consumer closed', callId); closeCall(callId); }); return { id: videoCalls[callId].consumerAudio.id, producerId: videoCalls[callId].producerAudio.id, kind: 'audio', rtpParameters: videoCalls[callId].consumerAudio.rtpParameters, } } /* - Called from at event 'createWebRtcTransport' and assigned to the consumer or producer transport - It will return parameters, these are required for the client to create the RecvTransport from the client. - If the client is producer(sender: true) then it will use parameters for device.createSendTransport(params) - If the client is a consumer(sender: false) then it will use parameters for device.createRecvTransport(params) */ const createWebRtcTransportLayer = async (callId, callback) => { try { console.log('[createWebRtcTransportLayer] callId', callId); // https://mediasoup.org/documentation/v3/mediasoup/api/#WebRtcTransportOptions const webRtcTransport_options = { listenIps: [ { ip: process.env.IP, // Listening IPv4 or IPv6. announcedIp: process.env.ANNOUNCED_IP, // Announced IPv4 or IPv6 (useful when running mediasoup behind NAT with private IP). } ], enableUdp: true, enableTcp: true, preferUdp: true, }; // https://mediasoup.org/documentation/v3/mediasoup/api/#router-createWebRtcTransport let transport = await videoCalls[callId].router.createWebRtcTransport(webRtcTransport_options) console.log(`callId: ${callId} | transport id: ${transport.id}`) // Handler for when DTLS(Datagram Transport Layer Security) changes transport.on('dtlsstatechange', dtlsState => { console.log(`transport | dtlsstatechange | calldId ${callId} | dtlsState ${dtlsState}`); if (dtlsState === 'closed') { transport.close(); } }); // Handler if the transport layer has closed (for various reasons) transport.on('close', () => { console.log(`transport | closed | calldId ${callId}`); }); const params = { id: transport.id, iceParameters: transport.iceParameters, iceCandidates: transport.iceCandidates, dtlsParameters: transport.dtlsParameters, }; console.log('[createWebRtcTransportLayer] callback params', params); // Send back to the client the params callback({ params }); // Set transport to producerTransport or consumerTransport return transport; } catch (error) { console.log(`ERROR | createWebRtcTransportLayer | callId ${socketDetails[socket.id]} | ${error.message}`); callback({ params: { error } }); } }