diff --git a/server/lib/Room.js b/server/lib/Room.js index b61402a5b..ded359578 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -303,6 +303,16 @@ class Room extends EventEmitter return this._mediasoupRouter.rtpCapabilities; } + /** + * Get a Broadcaster. + * + * @type {String} broadcasterId - Broadcaster id. + */ + getBroadcaster({ broadcasterId }) + { + return this._broadcasters.get(broadcasterId); + } + /** * Create a Broadcaster. This is for HTTP API requests (see server.js). * @@ -561,6 +571,40 @@ class Room extends EventEmitter } } + /** + * Restart ICE for a Broadcaster mediasoup WebRtcTransport. + * + * @async + * + * @type {String} broadcasterId + * @type {String} transportId + */ + async restartBroadcasterTransportICE( + { + broadcasterId, + transportId + } + ) + { + const broadcaster = this._broadcasters.get(broadcasterId); + + if (!broadcaster) + throw new Error(`broadcaster with id "${broadcasterId}" does not exist`); + + const transport = broadcaster.data.transports.get(transportId); + + if (!transport) + throw new Error(`transport with id "${transportId}" does not exist`); + + if (transport.constructor.name !== 'WebRtcTransport') + { + throw new Error( + `transport with id "${transportId}" is not a WebRtcTransport`); + } + + return await transport.restartIce(); + } + /** * Create a mediasoup Producer associated to a Broadcaster. * diff --git a/server/package.json b/server/package.json index e0eb19370..1bb2a999b 100644 --- a/server/package.json +++ b/server/package.json @@ -17,11 +17,14 @@ "awaitqueue": "^2.3.3", "body-parser": "^1.19.0", "colors": "^1.4.0", + "cors": "^2.8.5", "debug": "^4.3.1", "express": "^4.17.1", "mediasoup": "github:versatica/mediasoup#v3", + "mediasoup-client": "github:versatica/mediasoup-client#v3", "pidusage": "^2.0.21", "protoo-server": "^4.0.5", + "sdp-transform": "^2.14.1", "rtp.js": "^0.11.4" }, "devDependencies": { diff --git a/server/server.js b/server/server.js index 4378c2a6a..47bfb92e0 100755 --- a/server/server.js +++ b/server/server.js @@ -17,7 +17,6 @@ const url = require('url'); const protoo = require('protoo-server'); const mediasoup = require('mediasoup'); const express = require('express'); -const bodyParser = require('body-parser'); const { AwaitQueue } = require('awaitqueue'); const throttle = require('@sitespeed.io/throttle'); const Logger = require('./lib/Logger'); @@ -25,6 +24,13 @@ const utils = require('./lib/utils'); const Room = require('./lib/Room'); const interactiveServer = require('./lib/interactiveServer'); const interactiveClient = require('./lib/interactiveClient'); +const sdpTransform = require('sdp-transform'); +const sdpCommonUtils = require('mediasoup-client/lib/handlers/sdp/commonUtils'); +const ortc = require('mediasoup-client/lib/ortc'); +const { RemoteSdp } = require('mediasoup-client/lib/handlers/sdp/RemoteSdp'); +const sdpUnifiedPlanUtils = require('mediasoup-client/lib/handlers/sdp/unifiedPlanUtils'); +const utils = require('mediasoup-client/lib/utils'); +const cors = require('cors'); const logger = new Logger(); @@ -180,14 +186,26 @@ async function createExpressApp() expressApp = express(); - expressApp.use(bodyParser.json()); + expressApp.use(express.json()); + expressApp.use(express.text({ + type : [ + 'application/sdp', + 'application/trickle-ice-sdpfrag', + 'text/plain' + ] + })); + expressApp.use( + cors({ + origin : true + }) + ); /** * For every API request, verify that the roomId in the path matches and * existing room. */ expressApp.param( - 'roomId', (req, res, next, roomId) => + 'roomId', async (req, res, next, roomId) => { queue.push(async () => { @@ -476,6 +494,191 @@ async function createExpressApp() } }); + /** + * WHIP post handler. + */ + expressApp.post( + '/whip/:roomId/:broadcasterId', async (req, res, next) => + { + logger.info('whip POST', req.params, req.headers, req.body); + const { broadcasterId } = req.params; + + try + { + const localSdpObject = sdpTransform.parse(req.body); + + const rtpCapabilities = sdpCommonUtils.extractRtpCapabilities( + { sdpObject: localSdpObject }); + const dtlsParameters = sdpCommonUtils.extractDtlsParameters( + { sdpObject: localSdpObject }); + + const routerRtpCapabilities = req.room.getRouterRtpCapabilities(); + const extendedRtpCapabilities = ortc.getExtendedRtpCapabilities( + rtpCapabilities, routerRtpCapabilities); + + const sendingRtpParametersByKind = + { + audio : ortc.getSendingRtpParameters('audio', extendedRtpCapabilities), + video : ortc.getSendingRtpParameters('video', extendedRtpCapabilities) + }; + const sendingRemoteRtpParametersByKind = + { + audio : ortc.getSendingRemoteRtpParameters('audio', extendedRtpCapabilities), + video : ortc.getSendingRemoteRtpParameters('video', extendedRtpCapabilities) + }; + + // Create a broadcaster, if it not exists. + let broadcaster = req.room.getBroadcaster({ broadcasterId }); + + if (!broadcaster) + { + await req.room.createBroadcaster({ + id : broadcasterId, + displayName : 'WHIP broadcaster', + device : { name: 'WHIP device' }, + rtpCapabilities + }); + broadcaster = req.room.getBroadcaster({ broadcasterId }); + } + + // Create a WebRTC transport. + const transport = await req.room.createBroadcasterTransport({ + broadcasterId, + type : 'webrtc' + }); + + // Connect the WebRTC transport. + await req.room.connectBroadcasterTransport({ + broadcasterId, + transportId : transport.id, + dtlsParameters + }); + + const remoteSdp = new RemoteSdp({ + iceParameters : transport.iceParameters, + iceCandidates : transport.iceCandidates, + dtlsParameters : transport.dtlsParameters, + sctpParameters : transport.sctpParameters + }); + + broadcaster.data.transports.get(transport.id).appData.remoteSdp = remoteSdp; + + // Publish audio and video. + for (const { type, mid } of localSdpObject.media) + { + const mediaSectionIdx = remoteSdp.getNextMediaSectionIdx(); + const offerMediaObject = localSdpObject.media[mediaSectionIdx.idx]; + + const sendingRtpParameters = + utils.clone(sendingRtpParametersByKind[type], {}); + + const sendingRemoteRtpParameters = + utils.clone(sendingRemoteRtpParametersByKind[type], {}); + + // Set MID. + sendingRtpParameters.mid = String(mid); + + // Set RTCP CNAME. + sendingRtpParameters.rtcp.cname = + sdpCommonUtils.getCname({ offerMediaObject }); + + // Set RTP encodings by parsing the SDP offer. + sendingRtpParameters.encodings = + sdpUnifiedPlanUtils.getRtpEncodings({ offerMediaObject }); + + remoteSdp.send({ + offerMediaObject, + reuseMid : mediaSectionIdx.reuseMid, + offerRtpParameters : sendingRtpParameters, + answerRtpParameters : sendingRemoteRtpParameters, + codecOptions : {}, + extmapAllowMixed : true + }); + + await req.room.createBroadcasterProducer({ + broadcasterId, + transportId : transport.id, + kind : type, + rtpParameters : sendingRtpParameters + }); + } + const answer = remoteSdp.getSdp(); + + res.contentType('application/sdp') + .status(201) + .send(answer); + } + catch (error) + { + next(error); + } + }); + + /** + * WHIP patch handler. + */ + expressApp.patch( + '/whip/:roomId/:broadcasterId', async (req, res, next) => + { + logger.info('whip PATCH', req.params, req.headers, req.body); + const { broadcasterId } = req.params; + + try + { + const broadcaster = req.room.getBroadcaster({ broadcasterId }); + + if (!broadcaster) + throw Error(`broadcaster with id "${broadcasterId}" does not exist`); + + if (!broadcaster.data.transports.size) + throw Error(`broadcaster with id "${broadcasterId}" has no transports`); + + const transport = [ ...broadcaster.data.transports.values() ][0]; + const { remoteSdp } = transport.appData; + + if (!remoteSdp) + throw Error(`broadcaster with id "${broadcasterId}" has no remote SDP set`); + + const iceParameters = await req.room.restartBroadcasterTransportICE({ + broadcasterId, + transportId : transport.id + }); + + remoteSdp.updateIceParameters(iceParameters); + + const answer = remoteSdp.getSdp(); + + res.contentType('application/sdp') + .status(200) + .send(answer); + } + catch (error) + { + next(error); + } + }); + + /** + * WHIP delete handler. + */ + expressApp.delete( + '/whip/:roomId/:broadcasterId', async (req, res, next) => + { + logger.info('whip DELETE', req.params, req.headers); + const { broadcasterId } = req.params; + + try + { + req.room.deleteBroadcaster({ broadcasterId }); + res.contentType('text/plain').status(200) + .send(); + } + catch (error) + { + next(error); + } + }); + /** * Error handler. */