diff --git a/app.js b/app.js index cf9ddee..adbf45e 100644 --- a/app.js +++ b/app.js @@ -1,597 +1,597 @@ -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 - Dictionary of Object(s) - * '': { - * router: Router, - * initiatorAudioProducer: Producer, - * initiatorVideoProducer: Producer, - * receiverVideoProducer: Producer, - * receiverAudioProducer: Producer, - * initiatorProducerTransport: Producer Transport, - * receiverProducerTransport: Producer Transport, - * initiatorConsumerVideo: Consumer, - * initiatorConsumerAudio: Consumer, - * initiatorConsumerTransport: Consumer Transport - * initiatorSocket - * receiverSocket - * } - * - **/ -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: ['*:*'], -}); - -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.error(`[createWorker] | ERROR | error: ${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, - }, - }, -]; - -const closeCall = (callId) => { - try { - if (callId && videoCalls[callId]) { - videoCalls[callId].receiverVideoProducer?.close(); - videoCalls[callId].receiverAudioProducer?.close(); - videoCalls[callId].initiatorConsumerVideo?.close(); - videoCalls[callId].initiatorConsumerAudio?.close(); - - videoCalls[callId]?.initiatorConsumerTransport?.close(); - videoCalls[callId]?.receiverProducerTransport?.close(); - videoCalls[callId]?.router?.close(); - delete videoCalls[callId]; - console.log(`[closeCall] | callId: ${callId}`); - } - } catch (error) { - console.error(`[closeCall] | ERROR | callId: ${callId} | error: ${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]) { - videoCalls[callId] = { router: await worker.createRouter({ mediaCodecs }) }; - console.log(`[createRoom] Generate Router ID: ${videoCalls[callId].router.id}`); - videoCalls[callId].receiverSocket = socket; - } else { - videoCalls[callId].initiatorSocket = socket; - } - socketDetails[socket.id] = callId; - // rtpCapabilities is set for callback - callbackResponse = { - rtpCapabilities: videoCalls[callId].router.rtpCapabilities, - }; - } else { - console.log(`[createRoom] missing callId: ${callId}`); - } - } catch (error) { - console.error(`[createRoom] | ERROR | callId: ${callId} | error: ${error.message}`); - } finally { - callback(callbackResponse); - } - }); - - /* - - Client emits a request to create server side Transport - - Depending on the sender, a producer or consumer is created 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] socket ${socket.id} | sender ${sender} | callId ${callId}`); - if (sender) { - if (!videoCalls[callId].receiverProducerTransport && !isInitiator(callId, socket.id)) { - videoCalls[callId].receiverProducerTransport = await createWebRtcTransportLayer(callId, callback); - } else if (!videoCalls[callId].initiatorProducerTransport && isInitiator(callId, socket.id)) { - videoCalls[callId].initiatorProducerTransport = await createWebRtcTransportLayer(callId, callback); - } else { - console.log(`producerTransport has already been defined | callId ${callId}`); - callback(null); - } - } else if (!sender) { - if (!videoCalls[callId].receiverConsumerTransport && !isInitiator(callId, socket.id)) { - videoCalls[callId].receiverConsumerTransport = await createWebRtcTransportLayer(callId, callback); - } else if (!videoCalls[callId].initiatorConsumerTransport && isInitiator(callId, socket.id)) { - videoCalls[callId].initiatorConsumerTransport = await createWebRtcTransportLayer(callId, callback); - } - } - } catch (error) { - console.error( - `[createWebRtcTransport] | ERROR | callId: ${socketDetails[socket.id]} | sender: ${sender} | error: ${ - 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 ${socket.id} | callId ${callId}`); - - isInitiator(callId, socket.id) - ? await videoCalls[callId].initiatorProducerTransport.connect({ dtlsParameters }) - : await videoCalls[callId].receiverProducerTransport.connect({ dtlsParameters }); - } catch (error) { - console.error(`[transport-connect] | ERROR | callId: ${socketDetails[socket.id]} | error: ${error.message}`); - } - }); - - /* - - The event sent by the client (PRODUCER) after successfully connecting to receiverProducerTransport/initiatorProducerTransport - - For the router with the id callId, we make produce on receiverProducerTransport/initiatorProducerTransport - - 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] callId: ${callId} | kind: ${kind} | socket: ${socket.id}`); - - if (kind === 'video') { - if (!isInitiator(callId, socket.id)) { - videoCalls[callId].receiverVideoProducer = await videoCalls[callId].receiverProducerTransport.produce({ - kind, - rtpParameters, - }); - - videoCalls[callId].receiverVideoProducer.on('transportclose', () => { - console.log('transport for this producer closed', callId); - closeCall(callId); - }); - - // Send back to the client the Producer's id - callback && - callback({ - id: videoCalls[callId].receiverVideoProducer.id, - }); - } else { - videoCalls[callId].initiatorVideoProducer = await videoCalls[callId].initiatorProducerTransport.produce({ - kind, - rtpParameters, - }); - - videoCalls[callId].initiatorVideoProducer.on('transportclose', () => { - console.log('transport for this producer closed', callId); - closeCall(callId); - }); - - callback && - callback({ - id: videoCalls[callId].initiatorVideoProducer.id, - }); - } - } else if (kind === 'audio') { - if (!isInitiator(callId, socket.id)) { - videoCalls[callId].receiverAudioProducer = await videoCalls[callId].receiverProducerTransport.produce({ - kind, - rtpParameters, - }); - - videoCalls[callId].receiverAudioProducer.on('transportclose', () => { - console.log('transport for this producer closed', callId); - closeCall(callId); - }); - - // Send back to the client the Producer's id - callback && - callback({ - id: videoCalls[callId].receiverAudioProducer.id, - }); - } else { - videoCalls[callId].initiatorAudioProducer = await videoCalls[callId].initiatorProducerTransport.produce({ - kind, - rtpParameters, - }); - - videoCalls[callId].initiatorAudioProducer.on('transportclose', () => { - console.log('transport for this producer closed', callId); - closeCall(callId); - }); - - // Send back to the client the Producer's id - callback && - callback({ - id: videoCalls[callId].initiatorAudioProducer.id, - }); - } - } - - const socketToEmit = isInitiator(callId, socket.id) - ? videoCalls[callId].receiverSocket - : videoCalls[callId].initiatorSocket; - - // callId - Id of the call - // kind - producer type: audio/video - socketToEmit?.emit('new-producer', { callId, kind }); - } catch (error) { - console.error(`[transport-produce] | ERROR | callId: ${socketDetails[socket.id]} | error: ${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 ${socket.id} | callId ${callId}`); - if (typeof dtlsParameters === 'string') dtlsParameters = JSON.parse(dtlsParameters); - // await videoCalls[callId].consumerTransport.connect({ dtlsParameters }); - if (!isInitiator(callId, socket.id)) { - await videoCalls[callId].receiverConsumerTransport.connect({ dtlsParameters }); - } else if (isInitiator(callId, socket.id)) { - await videoCalls[callId].initiatorConsumerTransport.connect({ dtlsParameters }); - } - } catch (error) { - console.error(`[transport-recv-connect] | ERROR | callId: ${socketDetails[socket.id]} | error: ${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) => { - const callId = socketDetails[socket.id]; - const socketId = socket.id; - - console.log(`[consume] socket ${socketId} | callId: ${callId}`); - - if (typeof rtpCapabilities === 'string') rtpCapabilities = JSON.parse(rtpCapabilities); - - callback({ - videoParams: await consumeVideo({ callId, socketId, rtpCapabilities }), - audioParams: await consumeAudio({ callId, socketId, rtpCapabilities }), - }); - }); - - /* - - 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 - - For the initiator we resume the initiatorConsumerAUDIO/VIDEO and for receiver the receiverConsumerAUDIO/VIDEO - */ - socket.on('consumer-resume', () => { - try { - const callId = socketDetails[socket.id]; - const isInitiatorValue = isInitiator(callId, socket.id); - console.log(`[consumer-resume] callId: ${callId} | isInitiator: ${isInitiatorValue}`); - - const consumerVideo = isInitiatorValue - ? videoCalls[callId].initiatorConsumerVideo - : videoCalls[callId].receiverConsumerVideo; - - const consumerAudio = isInitiatorValue - ? videoCalls[callId].initiatorConsumerAudio - : videoCalls[callId].receiverConsumerAudio; - - consumerVideo?.resume(); - consumerAudio?.resume(); - } catch (error) { - console.error( - `[consumer-resume] | ERROR | callId: ${socketDetails[socket.id]} | isInitiator: ${isInitiator} | error: ${ - error.message - }` - ); - } - }); - - socket.on('close-producer', ({ callId, kind }) => { - try { - if (isInitiator(callId, socket.id)) { - console.log(`[close-producer] initiator --EMIT--> receiver | callId: ${callId} | kind: ${kind}`); - videoCalls[callId].receiverSocket.emit('close-producer', { callId, kind }); - } else { - console.log(`[close-producer] receiver --EMIT--> initiator | callId: ${callId} | kind: ${kind}`); - videoCalls[callId].initiatorSocket.emit('close-producer', { callId, kind }); - } - } catch (error) { - console.error(`[close-producer] | ERROR | callId: ${socketDetails[socket.id]} | error: ${error.message}`); - } - }); -}); - -const canConsume = ({ callId, producerId, rtpCapabilities }) => { - return !!videoCalls[callId].router.canConsume({ - producerId, - rtpCapabilities, - }); -}; - -const consumeVideo = async ({ callId, socketId, rtpCapabilities }) => { - // Handlers for consumer transport https://mediasoup.org/documentation/v3/mediasoup/api/#consumer-on-transportclose - if (isInitiator(callId, socketId) && videoCalls[callId].receiverVideoProducer) { - const producerId = videoCalls[callId].receiverVideoProducer.id; - if (!canConsume({ callId, producerId, rtpCapabilities })) return null; - - videoCalls[callId].initiatorConsumerVideo = await videoCalls[callId].initiatorConsumerTransport.consume({ - producerId, - rtpCapabilities, - paused: true, - }); - - return { - id: videoCalls[callId].initiatorConsumerVideo.id, - producerId, - kind: 'video', - rtpParameters: videoCalls[callId].initiatorConsumerVideo.rtpParameters, - }; - } else if (videoCalls[callId].initiatorVideoProducer) { - const producerId = videoCalls[callId].initiatorVideoProducer.id; - if (!canConsume({ callId, producerId, rtpCapabilities })) return null; - - videoCalls[callId].receiverConsumerVideo = await videoCalls[callId].receiverConsumerTransport.consume({ - producerId, - rtpCapabilities, - paused: true, - }); - - return { - id: videoCalls[callId].receiverConsumerVideo.id, - producerId, - kind: 'video', - rtpParameters: videoCalls[callId].receiverConsumerVideo.rtpParameters, - }; - } else { - return null; - } -}; - -const consumeAudio = async ({ callId, socketId, rtpCapabilities }) => { - try { - // Handlers for consumer transport https://mediasoup.org/documentation/v3/mediasoup/api/#consumer-on-transportclose - if (isInitiator(callId, socketId) && videoCalls[callId].receiverAudioProducer) { - const producerId = videoCalls[callId].receiverAudioProducer.id; - if (!canConsume({ callId, producerId, rtpCapabilities })) return null; - - videoCalls[callId].initiatorConsumerAudio = await videoCalls[callId].initiatorConsumerTransport.consume({ - producerId, - rtpCapabilities, - paused: true, - }); - - return { - id: videoCalls[callId].initiatorConsumerAudio.id, - producerId, - kind: 'audio', - rtpParameters: videoCalls[callId].initiatorConsumerAudio.rtpParameters, - }; - } else if (videoCalls[callId].initiatorAudioProducer) { - const producerId = videoCalls[callId].initiatorAudioProducer.id; - if (!canConsume({ callId, producerId, rtpCapabilities })) return null; - - videoCalls[callId].receiverConsumerAudio = await videoCalls[callId].receiverConsumerTransport.consume({ - producerId, - rtpCapabilities, - paused: true, - }); - - return { - id: videoCalls[callId].receiverConsumerAudio.id, - producerId, - kind: 'audio', - rtpParameters: videoCalls[callId].receiverConsumerAudio.rtpParameters, - }; - } else { - return null; - } - } catch (error) { - console.error(`[consumeAudio] | ERROR | error: ${error}`); - } -}; - -const isInitiator = (callId, socketId) => { - return videoCalls[callId]?.initiatorSocket?.id === socketId; -}; - -/* - - 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); - - // 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, - }; - - // Send back to the client the params - callback({ params }); - - // Set transport to producerTransport or consumerTransport - return transport; - } catch (error) { - console.error( - `[createWebRtcTransportLayer] | ERROR | callId: ${socketDetails[socket.id]} | error: ${error.message}` - ); - callback({ params: { error } }); - } -}; +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 - Dictionary of Object(s) + * '': { + * router: Router, + * initiatorAudioProducer: Producer, + * initiatorVideoProducer: Producer, + * receiverVideoProducer: Producer, + * receiverAudioProducer: Producer, + * initiatorProducerTransport: Producer Transport, + * receiverProducerTransport: Producer Transport, + * initiatorConsumerVideo: Consumer, + * initiatorConsumerAudio: Consumer, + * initiatorConsumerTransport: Consumer Transport + * initiatorSocket + * receiverSocket + * } + * + **/ +let videoCalls = {}; +let socketDetails = {}; + +app.get('/', (_req, res) => { + res.send('OK'); +}); + +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: ['*:*'], +}); + +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.error(`[createWorker] | ERROR | error: ${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, + }, + }, +]; + +const closeCall = (callId) => { + try { + if (callId && videoCalls[callId]) { + videoCalls[callId].receiverVideoProducer?.close(); + videoCalls[callId].receiverAudioProducer?.close(); + videoCalls[callId].initiatorConsumerVideo?.close(); + videoCalls[callId].initiatorConsumerAudio?.close(); + + videoCalls[callId]?.initiatorConsumerTransport?.close(); + videoCalls[callId]?.receiverProducerTransport?.close(); + videoCalls[callId]?.router?.close(); + delete videoCalls[callId]; + console.log(`[closeCall] | callId: ${callId}`); + } + } catch (error) { + console.error(`[closeCall] | ERROR | callId: ${callId} | error: ${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]) { + videoCalls[callId] = { router: await worker.createRouter({ mediaCodecs }) }; + console.log(`[createRoom] Generate Router ID: ${videoCalls[callId].router.id}`); + videoCalls[callId].receiverSocket = socket; + } else { + videoCalls[callId].initiatorSocket = socket; + } + socketDetails[socket.id] = callId; + // rtpCapabilities is set for callback + callbackResponse = { + rtpCapabilities: videoCalls[callId].router.rtpCapabilities, + }; + } else { + console.log(`[createRoom] missing callId: ${callId}`); + } + } catch (error) { + console.error(`[createRoom] | ERROR | callId: ${callId} | error: ${error.message}`); + } finally { + callback(callbackResponse); + } + }); + + /* + - Client emits a request to create server side Transport + - Depending on the sender, a producer or consumer is created 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] socket ${socket.id} | sender ${sender} | callId ${callId}`); + if (sender) { + if (!videoCalls[callId].receiverProducerTransport && !isInitiator(callId, socket.id)) { + videoCalls[callId].receiverProducerTransport = await createWebRtcTransportLayer(callId, callback); + } else if (!videoCalls[callId].initiatorProducerTransport && isInitiator(callId, socket.id)) { + videoCalls[callId].initiatorProducerTransport = await createWebRtcTransportLayer(callId, callback); + } else { + console.log(`producerTransport has already been defined | callId ${callId}`); + callback(null); + } + } else if (!sender) { + if (!videoCalls[callId].receiverConsumerTransport && !isInitiator(callId, socket.id)) { + videoCalls[callId].receiverConsumerTransport = await createWebRtcTransportLayer(callId, callback); + } else if (!videoCalls[callId].initiatorConsumerTransport && isInitiator(callId, socket.id)) { + videoCalls[callId].initiatorConsumerTransport = await createWebRtcTransportLayer(callId, callback); + } + } + } catch (error) { + console.error( + `[createWebRtcTransport] | ERROR | callId: ${socketDetails[socket.id]} | sender: ${sender} | error: ${ + 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 ${socket.id} | callId ${callId}`); + + isInitiator(callId, socket.id) + ? await videoCalls[callId].initiatorProducerTransport.connect({ dtlsParameters }) + : await videoCalls[callId].receiverProducerTransport.connect({ dtlsParameters }); + } catch (error) { + console.error(`[transport-connect] | ERROR | callId: ${socketDetails[socket.id]} | error: ${error.message}`); + } + }); + + /* + - The event sent by the client (PRODUCER) after successfully connecting to receiverProducerTransport/initiatorProducerTransport + - For the router with the id callId, we make produce on receiverProducerTransport/initiatorProducerTransport + - 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] callId: ${callId} | kind: ${kind} | socket: ${socket.id}`); + + if (kind === 'video') { + if (!isInitiator(callId, socket.id)) { + videoCalls[callId].receiverVideoProducer = await videoCalls[callId].receiverProducerTransport.produce({ + kind, + rtpParameters, + }); + + videoCalls[callId].receiverVideoProducer.on('transportclose', () => { + console.log('transport for this producer closed', callId); + closeCall(callId); + }); + + // Send back to the client the Producer's id + callback && + callback({ + id: videoCalls[callId].receiverVideoProducer.id, + }); + } else { + videoCalls[callId].initiatorVideoProducer = await videoCalls[callId].initiatorProducerTransport.produce({ + kind, + rtpParameters, + }); + + videoCalls[callId].initiatorVideoProducer.on('transportclose', () => { + console.log('transport for this producer closed', callId); + closeCall(callId); + }); + + callback && + callback({ + id: videoCalls[callId].initiatorVideoProducer.id, + }); + } + } else if (kind === 'audio') { + if (!isInitiator(callId, socket.id)) { + videoCalls[callId].receiverAudioProducer = await videoCalls[callId].receiverProducerTransport.produce({ + kind, + rtpParameters, + }); + + videoCalls[callId].receiverAudioProducer.on('transportclose', () => { + console.log('transport for this producer closed', callId); + closeCall(callId); + }); + + // Send back to the client the Producer's id + callback && + callback({ + id: videoCalls[callId].receiverAudioProducer.id, + }); + } else { + videoCalls[callId].initiatorAudioProducer = await videoCalls[callId].initiatorProducerTransport.produce({ + kind, + rtpParameters, + }); + + videoCalls[callId].initiatorAudioProducer.on('transportclose', () => { + console.log('transport for this producer closed', callId); + closeCall(callId); + }); + + // Send back to the client the Producer's id + callback && + callback({ + id: videoCalls[callId].initiatorAudioProducer.id, + }); + } + } + + const socketToEmit = isInitiator(callId, socket.id) + ? videoCalls[callId].receiverSocket + : videoCalls[callId].initiatorSocket; + + // callId - Id of the call + // kind - producer type: audio/video + socketToEmit?.emit('new-producer', { callId, kind }); + } catch (error) { + console.error(`[transport-produce] | ERROR | callId: ${socketDetails[socket.id]} | error: ${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 ${socket.id} | callId ${callId}`); + if (typeof dtlsParameters === 'string') dtlsParameters = JSON.parse(dtlsParameters); + // await videoCalls[callId].consumerTransport.connect({ dtlsParameters }); + if (!isInitiator(callId, socket.id)) { + await videoCalls[callId].receiverConsumerTransport.connect({ dtlsParameters }); + } else if (isInitiator(callId, socket.id)) { + await videoCalls[callId].initiatorConsumerTransport.connect({ dtlsParameters }); + } + } catch (error) { + console.error(`[transport-recv-connect] | ERROR | callId: ${socketDetails[socket.id]} | error: ${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) => { + const callId = socketDetails[socket.id]; + const socketId = socket.id; + + console.log(`[consume] socket ${socketId} | callId: ${callId}`); + + if (typeof rtpCapabilities === 'string') rtpCapabilities = JSON.parse(rtpCapabilities); + + callback({ + videoParams: await consumeVideo({ callId, socketId, rtpCapabilities }), + audioParams: await consumeAudio({ callId, socketId, rtpCapabilities }), + }); + }); + + /* + - 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 + - For the initiator we resume the initiatorConsumerAUDIO/VIDEO and for receiver the receiverConsumerAUDIO/VIDEO + */ + socket.on('consumer-resume', () => { + try { + const callId = socketDetails[socket.id]; + const isInitiatorValue = isInitiator(callId, socket.id); + console.log(`[consumer-resume] callId: ${callId} | isInitiator: ${isInitiatorValue}`); + + const consumerVideo = isInitiatorValue + ? videoCalls[callId].initiatorConsumerVideo + : videoCalls[callId].receiverConsumerVideo; + + const consumerAudio = isInitiatorValue + ? videoCalls[callId].initiatorConsumerAudio + : videoCalls[callId].receiverConsumerAudio; + + consumerVideo?.resume(); + consumerAudio?.resume(); + } catch (error) { + console.error( + `[consumer-resume] | ERROR | callId: ${socketDetails[socket.id]} | isInitiator: ${isInitiator} | error: ${ + error.message + }` + ); + } + }); + + socket.on('close-producer', ({ callId, kind }) => { + try { + if (isInitiator(callId, socket.id)) { + console.log(`[close-producer] initiator --EMIT--> receiver | callId: ${callId} | kind: ${kind}`); + videoCalls[callId].receiverSocket.emit('close-producer', { callId, kind }); + } else { + console.log(`[close-producer] receiver --EMIT--> initiator | callId: ${callId} | kind: ${kind}`); + videoCalls[callId].initiatorSocket.emit('close-producer', { callId, kind }); + } + } catch (error) { + console.error(`[close-producer] | ERROR | callId: ${socketDetails[socket.id]} | error: ${error.message}`); + } + }); +}); + +const canConsume = ({ callId, producerId, rtpCapabilities }) => { + return !!videoCalls[callId].router.canConsume({ + producerId, + rtpCapabilities, + }); +}; + +const consumeVideo = async ({ callId, socketId, rtpCapabilities }) => { + // Handlers for consumer transport https://mediasoup.org/documentation/v3/mediasoup/api/#consumer-on-transportclose + if (isInitiator(callId, socketId) && videoCalls[callId].receiverVideoProducer) { + const producerId = videoCalls[callId].receiverVideoProducer.id; + if (!canConsume({ callId, producerId, rtpCapabilities })) return null; + + videoCalls[callId].initiatorConsumerVideo = await videoCalls[callId].initiatorConsumerTransport.consume({ + producerId, + rtpCapabilities, + paused: true, + }); + + return { + id: videoCalls[callId].initiatorConsumerVideo.id, + producerId, + kind: 'video', + rtpParameters: videoCalls[callId].initiatorConsumerVideo.rtpParameters, + }; + } else if (videoCalls[callId].initiatorVideoProducer) { + const producerId = videoCalls[callId].initiatorVideoProducer.id; + if (!canConsume({ callId, producerId, rtpCapabilities })) return null; + + videoCalls[callId].receiverConsumerVideo = await videoCalls[callId].receiverConsumerTransport.consume({ + producerId, + rtpCapabilities, + paused: true, + }); + + return { + id: videoCalls[callId].receiverConsumerVideo.id, + producerId, + kind: 'video', + rtpParameters: videoCalls[callId].receiverConsumerVideo.rtpParameters, + }; + } else { + return null; + } +}; + +const consumeAudio = async ({ callId, socketId, rtpCapabilities }) => { + try { + // Handlers for consumer transport https://mediasoup.org/documentation/v3/mediasoup/api/#consumer-on-transportclose + if (isInitiator(callId, socketId) && videoCalls[callId].receiverAudioProducer) { + const producerId = videoCalls[callId].receiverAudioProducer.id; + if (!canConsume({ callId, producerId, rtpCapabilities })) return null; + + videoCalls[callId].initiatorConsumerAudio = await videoCalls[callId].initiatorConsumerTransport.consume({ + producerId, + rtpCapabilities, + paused: true, + }); + + return { + id: videoCalls[callId].initiatorConsumerAudio.id, + producerId, + kind: 'audio', + rtpParameters: videoCalls[callId].initiatorConsumerAudio.rtpParameters, + }; + } else if (videoCalls[callId].initiatorAudioProducer) { + const producerId = videoCalls[callId].initiatorAudioProducer.id; + if (!canConsume({ callId, producerId, rtpCapabilities })) return null; + + videoCalls[callId].receiverConsumerAudio = await videoCalls[callId].receiverConsumerTransport.consume({ + producerId, + rtpCapabilities, + paused: true, + }); + + return { + id: videoCalls[callId].receiverConsumerAudio.id, + producerId, + kind: 'audio', + rtpParameters: videoCalls[callId].receiverConsumerAudio.rtpParameters, + }; + } else { + return null; + } + } catch (error) { + console.error(`[consumeAudio] | ERROR | error: ${error}`); + } +}; + +const isInitiator = (callId, socketId) => { + return videoCalls[callId]?.initiatorSocket?.id === socketId; +}; + +/* + - 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); + + // 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, + }; + + // Send back to the client the params + callback({ params }); + + // Set transport to producerTransport or consumerTransport + return transport; + } catch (error) { + console.error( + `[createWebRtcTransportLayer] | ERROR | callId: ${socketDetails[socket.id]} | error: ${error.message}` + ); + callback({ params: { error } }); + } +};