├── .prettierrc.json ├── CODEOWNERS ├── .github └── workflows │ └── dialog.yml ├── scripts └── docker │ └── run.sh ├── Dockerfile ├── lib ├── interactiveClient.js ├── Logger.js ├── utils.js ├── interactiveServer.js └── Room.js ├── package.json ├── habitat ├── plan.sh └── hooks │ └── run ├── .gitignore ├── README.md ├── config.js ├── .eslintrc.js ├── index.js └── LICENSE /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } 4 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | 2 | .github/workflows @mozilla/xr 3 | -------------------------------------------------------------------------------- /.github/workflows/dialog.yml: -------------------------------------------------------------------------------- 1 | name: dialog 2 | on: 3 | push: 4 | branches: 5 | paths-ignore: ["README.md"] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | turkeyGitops: 10 | uses: mozilla/hubs-ops/.github/workflows/turkeyGitops.yml@master 11 | with: 12 | registry: mozillareality 13 | dockerfile: Dockerfile 14 | secrets: 15 | DOCKER_HUB_PWD: ${{ secrets.DOCKER_HUB_PWD }} 16 | -------------------------------------------------------------------------------- /scripts/docker/run.sh: -------------------------------------------------------------------------------- 1 | # TODO: need a better one 2 | PUB_IP_CURL=https://ipinfo.io/ip 3 | 4 | healthcheck(){ 5 | while true; do (echo -e 'HTTP/1.1 200 OK\r\n\r\n 1') | nc -lp 1111 > /dev/null; done 6 | } 7 | 8 | 9 | healthcheck & 10 | echo -e $(echo -e ${perms_key//\n/n}) > /app/certs/perms.pub.pem 11 | head -3 /app/certs/perms.pub.pem 12 | export MEDIASOUP_ANNOUNCED_IP=$(curl ${PUB_IP_CURL}) 13 | echo "MEDIASOUP_ANNOUNCED_IP: $MEDIASOUP_ANNOUNCED_IP" 14 | export INTERACTIVE=nope 15 | 16 | # npm start 17 | DEBUG='*INFO* *WARN* *ERROR*' node index.js -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | ARG NODE_VERSION=18 3 | 4 | FROM node:${NODE_VERSION} AS build 5 | workdir /app 6 | run apt-get update > /dev/null && apt-get -y install python3-pip > /dev/null 7 | run mkdir certs && openssl req -x509 -newkey rsa:2048 -sha256 -days 36500 -nodes -keyout certs/privkey.pem -out certs/fullchain.pem -subj '/CN=dialog' 8 | copy package.json . 9 | copy package-lock.json . 10 | run npm ci 11 | copy . . 12 | from node:lts-slim 13 | workdir /app 14 | copy --from=build /app /app 15 | run apt-get update > /dev/null && apt-get install -y jq curl dnsutils netcat > /dev/null 16 | copy scripts/docker/run.sh /run.sh 17 | cmd bash /run.sh 18 | -------------------------------------------------------------------------------- /lib/interactiveClient.js: -------------------------------------------------------------------------------- 1 | const net = require('net'); 2 | const os = require('os'); 3 | const path = require('path'); 4 | 5 | const SOCKET_PATH_UNIX = '/tmp/mediasoup-demo.sock'; 6 | const SOCKET_PATH_WIN = path.join('\\\\?\\pipe', process.cwd(), 'mediasoup-demo'); 7 | const SOCKET_PATH = os.platform() === 'win32'? SOCKET_PATH_WIN : SOCKET_PATH_UNIX; 8 | 9 | module.exports = async function() 10 | { 11 | const socket = net.connect(SOCKET_PATH); 12 | 13 | process.stdin.pipe(socket); 14 | socket.pipe(process.stdout); 15 | 16 | socket.on('connect', () => process.stdin.setRawMode(true)); 17 | socket.on('close', () => process.exit(0)); 18 | socket.on('exit', () => socket.end()); 19 | }; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dialog", 3 | "version": "0.0.1", 4 | "description": "WebRTC SFU based on mediasoup", 5 | "author": "Mozilla Mixed Reality ", 6 | "license": "MPL-2.0", 7 | "main": "index.js", 8 | "scripts": { 9 | "lint": "eslint -c .eslintrc.js index.js", 10 | "start": "DEBUG=${DEBUG:='*mediasoup* *INFO* *WARN* *ERROR*'} INTERACTIVE=${INTERACTIVE:='true'} node index.js" 11 | }, 12 | "dependencies": { 13 | "@sitespeed.io/throttle": "^3.0.0", 14 | "awaitqueue": "^2.4.0", 15 | "body-parser": "^1.19.0", 16 | "colors": "^1.4.0", 17 | "debug": "^4.1.1", 18 | "express": "^4.18.2", 19 | "heapdump": "^0.3.15", 20 | "jsonwebtoken": "^9.0.0", 21 | "mediasoup": "^3.11.4", 22 | "pidusage": "^3.0.2", 23 | "protoo-server": "^4.0.6", 24 | "sctp": "^1.0.0" 25 | }, 26 | "devDependencies": { 27 | "eslint": "^6.8.0", 28 | "eslint-config-prettier": "^4.1.0", 29 | "eslint-plugin-prettier": "^3.0.1", 30 | "prettier": "^1.16.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /habitat/plan.sh: -------------------------------------------------------------------------------- 1 | pkg_name=janus-gateway 2 | pkg_origin=mozillareality 3 | pkg_maintainer="Mozilla Mixed Reality " 4 | pkg_version="2.0.1" 5 | pkg_description="A simple mediasoup based SFU" 6 | 7 | pkg_deps=( 8 | core/node/18 9 | mozillareality/gcc-libs 10 | mozillareality/openssl 11 | ) 12 | 13 | pkg_build_deps=( 14 | core/git 15 | mozillareality/gcc 16 | core/make 17 | core/patchelf 18 | ) 19 | 20 | do_build() { 21 | CFLAGS="${CFLAGS} -O2 -g" CPPFLAGS="${CPPFLAGS} -O2 -g" CXXFLAGS="${CXXFLAGS} -O2 -g" npm ci 22 | patchelf --set-rpath "$(pkg_path_for gcc-libs)/lib" node_modules/mediasoup/worker/out/Release/mediasoup-worker 23 | pushd node_modules 24 | rm -rf mediasoup/worker/deps 25 | rm -rf mediasoup/worker/out/Release/obj.target 26 | rm -rf clang-tools-prebuilt 27 | rm -rf eslint 28 | rm -rf prettier 29 | popd 30 | } 31 | 32 | do_install() { 33 | cp -r ./*.js ./*.json ./lib ./node_modules "${pkg_prefix}/" 34 | rm -rf node_modules/mediasoup 35 | } 36 | 37 | do_strip() { 38 | return 0; 39 | } 40 | -------------------------------------------------------------------------------- /lib/Logger.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug'); 2 | 3 | const APP_NAME = 'mediasoup-demo-server'; 4 | 5 | class Logger 6 | { 7 | constructor(prefix) 8 | { 9 | if (prefix) 10 | { 11 | this._debug = debug(`${APP_NAME}:${prefix}`); 12 | this._info = debug(`${APP_NAME}:INFO:${prefix}`); 13 | this._warn = debug(`${APP_NAME}:WARN:${prefix}`); 14 | this._error = debug(`${APP_NAME}:ERROR:${prefix}`); 15 | } 16 | else 17 | { 18 | this._debug = debug(APP_NAME); 19 | this._info = debug(`${APP_NAME}:INFO`); 20 | this._warn = debug(`${APP_NAME}:WARN`); 21 | this._error = debug(`${APP_NAME}:ERROR`); 22 | } 23 | 24 | /* eslint-disable no-console */ 25 | this._debug.log = console.info.bind(console); 26 | this._info.log = console.info.bind(console); 27 | this._warn.log = console.warn.bind(console); 28 | this._error.log = console.error.bind(console); 29 | /* eslint-enable no-console */ 30 | } 31 | 32 | get debug() 33 | { 34 | return this._debug; 35 | } 36 | 37 | get info() 38 | { 39 | return this._info; 40 | } 41 | 42 | get warn() 43 | { 44 | return this._warn; 45 | } 46 | 47 | get error() 48 | { 49 | return this._error; 50 | } 51 | } 52 | 53 | module.exports = Logger; 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | .serverless 64 | results 65 | -------------------------------------------------------------------------------- /habitat/hooks/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | exec 2>&1 4 | 5 | set -e 6 | 7 | export MEDIASOUP_MIN_PORT=$(echo "{{ cfg.media.rtp_port_range }}" | cut -d- -f1) 8 | export MEDIASOUP_MAX_PORT=$(echo "{{ cfg.media.rtp_port_range }}" | cut -d- -f2) 9 | 10 | # HACKY - need to inject public IP here. Since not configured in legacy package for janus. 11 | # AWS 12 | export MEDIASOUP_ANNOUNCED_IP=$(curl -fs http://169.254.169.254/latest/meta-data/public-ipv4) 13 | 14 | # DigitalOcean 15 | if [[ -z "$MEDIASOUP_ANNOUNCED_IP" ]] ; then 16 | export MEDIASOUP_ANNOUNCED_IP=$(curl -fs "http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address") 17 | fi 18 | 19 | # Fallback to first eth0 ip 20 | if [[ -z "$MEDIASOUP_ANNOUNCED_IP" ]] ; then 21 | MEDIASOUP_ANNOUNCED_IP=$(ip -4 addr show eth0 | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -n1) 22 | fi 23 | 24 | # Convert legacy der file 25 | if [[ ! -f "{{ pkg.svc_files_path }}/perms.pub.pem" ]] ; then 26 | openssl rsa -in {{ pkg.svc_files_path }}/perms.pub.der -inform DER -RSAPublicKey_in -out {{ pkg.svc_files_path }}/perms.pub.pem 27 | fi 28 | 29 | export DOMAIN="$(hostname)" 30 | export HTTPS_CERT_PRIVKEY={{ pkg.svc_files_path }}/wss.key 31 | export HTTPS_CERT_FULLCHAIN={{ pkg.svc_files_path }}/wss.pem 32 | export AUTH_KEY={{ pkg.svc_files_path }}/perms.pub.pem 33 | export MEDIASOUP_LISTEN_IP={{ cfg.transports.http.admin_ip }} 34 | export PROTOO_LISTEN_PORT={{ cfg.transports.websockets.wss_port }} 35 | export ADMIN_LISTEN_PORT={{ cfg.transports.http.admin_port }} 36 | 37 | ( 38 | OLD_PRIVKEY=$(cat "$HTTPS_CERT_PRIVKEY") 39 | while true 40 | do 41 | if ! diff <(cat "$HTTPS_CERT_PRIVKEY") <(echo "$OLD_PRIVKEY") >/dev/null 2>&1; then 42 | echo "New SSL Cert detected; restarting Dialog process" 43 | kill $$ 44 | break 45 | fi 46 | sleep 3600 47 | done 48 | ) & 49 | 50 | exec node {{ pkg.path }}/index.js 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dialog 2 | Mediasoup based WebRTC SFU for Hubs. 3 | 4 | ## Development 5 | 1. Clone repo 6 | 2. In root project folder, `npm ci` (this may take a while). 7 | 3. Create a folder in the root project folder called `certs` if needed (see steps 4 & 5). 8 | 4. Add the ssl cert and key to the `certs` folder as `fullchain.pem` and `privkey.pem`, or set the path to these in your shell via `HTTPS_CERT_FULLCHAIN` and `HTTPS_CERT_PRIVKEY` respectively. You can provide these certs yourself or use the ones available in https://github.com/mozilla/reticulum/tree/master/priv (`dev-ssl.cert` and `dev-ssl.key`). 9 | 10 | 5. Add the reticulum permissions public key to the `certs` folder as `perms.pub.pem`, or set the path to the file in your shell via `AUTH_KEY`. 11 | 12 | * If using one of the public keys from hubs-ops (located in https://github.com/mozilla/hubs-ops/tree/master/ansible/roles/janus/files), you will need to convert it to standard pem format. 13 | * e.g. for use with dev.reticulum.io: `openssl rsa -in perms.pub.der.dev -inform DER -RSAPublicKey_in -out perms.pub.pem` 14 | 15 | 6. Start dialog with `MEDIASOUP_LISTEN_IP=XXX.XXX.XXX.XXX MEDIASOUP_ANNOUNCED_IP=XXX.XXX.XXX.XXX npm start` where `XXX.XXX.XXX.XXX` is the local IP address of the machine running the server. (In the case of a VM, this should be the internal IP address of the VM). 16 | * If you choose to set the paths for `HTTPS_CERT_FULLCHAIN`, `HTTPS_CERT_PRIVKEY` and/or `AUTH_KEY` you may also define them inline here as well. e.g. 17 | ``` 18 | HTTPS_CERT_FULLCHAIN=/path/to/cert.file HTTPS_CERT_PRIVKEY=/path/to/key.file AUTH_KEY=/path/to/auth.key MEDIASOUP_LISTEN_IP=XXX.XXX.XXX.XXX MEDIASOUP_ANNOUNCED_IP=XXX.XXX.XXX.XXX npm start 19 | ``` 20 | 21 | 7. Navigate to https://localhost:4443/ in your browser, and accept the self-signed cert. 22 | 23 | 8. You may now point Hubs/Reticulum to use `localhost:4443` as the WebRTC host/port.` 24 | 25 | See `config.js` for all available configuration options. 26 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * IMPORTANT (PLEASE READ THIS): 3 | * 4 | * This is not the "configuration file" of mediasoup. This is the configuration 5 | * file of the mediasoup-demo app. mediasoup itself is a server-side library, it 6 | * does not read any "configuration file". Instead it exposes an API. This demo 7 | * application just reads settings from this file (once copied to config.js) and 8 | * calls the mediasoup API with those settings when appropriate. 9 | */ 10 | 11 | const os = require('os'); 12 | 13 | const config = 14 | { 15 | // Listening hostname (just for `gulp live` task). 16 | domain : process.env.DOMAIN || 'localhost', 17 | // Signaling settings (protoo WebSocket server and HTTP API server). 18 | https : 19 | { 20 | listenIp : '0.0.0.0', 21 | // NOTE: Don't change listenPort (client app assumes 4443). 22 | listenPort : process.env.PROTOO_LISTEN_PORT || 4443, 23 | // NOTE: Set your own valid certificate files. 24 | tls : 25 | { 26 | cert : process.env.HTTPS_CERT_FULLCHAIN || `${__dirname}/certs/fullchain.pem`, 27 | key : process.env.HTTPS_CERT_PRIVKEY || `${__dirname}/certs/privkey.pem` 28 | } 29 | }, 30 | // TODO remove 31 | adminHttp : 32 | { 33 | listenIp : '0.0.0.0', 34 | // NOTE: Don't change listenPort (client app assumes 4443). 35 | listenPort : process.env.ADMIN_LISTEN_PORT || 7000 36 | }, 37 | // mediasoup settings. 38 | mediasoup : 39 | { 40 | // Number of mediasoup workers to launch. 41 | numWorkers : Object.keys(os.cpus()).length, 42 | // mediasoup WorkerSettings. 43 | // See https://mediasoup.org/documentation/v3/mediasoup/api/#WorkerSettings 44 | workerSettings : 45 | { 46 | logLevel : 'warn', 47 | logTags : 48 | [ 49 | 'info', 50 | 'ice', 51 | 'dtls', 52 | 'rtp', 53 | 'srtp', 54 | 'rtcp', 55 | 'rtx', 56 | 'bwe', 57 | 'score', 58 | 'simulcast', 59 | 'svc', 60 | 'sctp' 61 | ], 62 | rtcMinPort : process.env.MEDIASOUP_MIN_PORT || 40000, 63 | rtcMaxPort : process.env.MEDIASOUP_MAX_PORT || 49999 64 | }, 65 | // mediasoup Router options. 66 | // See https://mediasoup.org/documentation/v3/mediasoup/api/#RouterOptions 67 | routerOptions : 68 | { 69 | mediaCodecs : 70 | [ 71 | { 72 | kind : 'audio', 73 | mimeType : 'audio/opus', 74 | clockRate : 48000, 75 | channels : 2 76 | }, 77 | { 78 | kind : 'video', 79 | mimeType : 'video/VP8', 80 | clockRate : 90000, 81 | parameters : 82 | { 83 | 'x-google-start-bitrate' : 1000 84 | } 85 | }, 86 | { 87 | kind : 'video', 88 | mimeType : 'video/VP9', 89 | clockRate : 90000, 90 | parameters : 91 | { 92 | 'profile-id' : 2, 93 | 'x-google-start-bitrate' : 1000 94 | } 95 | }, 96 | { 97 | kind : 'video', 98 | mimeType : 'video/h264', 99 | clockRate : 90000, 100 | parameters : 101 | { 102 | 'packetization-mode' : 1, 103 | 'profile-level-id' : '4d0032', 104 | 'level-asymmetry-allowed' : 1, 105 | 'x-google-start-bitrate' : 1000 106 | } 107 | }, 108 | { 109 | kind : 'video', 110 | mimeType : 'video/h264', 111 | clockRate : 90000, 112 | parameters : 113 | { 114 | 'packetization-mode' : 1, 115 | 'profile-level-id' : '42e01f', 116 | 'level-asymmetry-allowed' : 1, 117 | 'x-google-start-bitrate' : 1000 118 | } 119 | } 120 | ] 121 | }, 122 | // mediasoup WebRtcTransport options for WebRTC endpoints (mediasoup-client, 123 | // libmediasoupclient). 124 | // See https://mediasoup.org/documentation/v3/mediasoup/api/#WebRtcTransportOptions 125 | webRtcTransportOptions : 126 | { 127 | listenIps : 128 | [ 129 | { 130 | ip : process.env.MEDIASOUP_LISTEN_IP || '127.0.0.1' 131 | }, 132 | { 133 | ip : '0.0.0.0', 134 | announcedIp : process.env.MEDIASOUP_ANNOUNCED_IP 135 | } 136 | ], 137 | initialAvailableOutgoingBitrate : 1000000, 138 | minimumAvailableOutgoingBitrate : 600000, 139 | maxSctpMessageSize : 262144, 140 | // Additional options that are not part of WebRtcTransportOptions. 141 | maxIncomingBitrate : 1500000 142 | } 143 | }, 144 | authKey: process.env.AUTH_KEY || `${__dirname}/certs/perms.pub.pem` 145 | }; 146 | 147 | if (process.env.MEDIASOUP_ANNOUNCED_IP) 148 | { 149 | // For now we have to bind to 0.0.0.0 to ensure TURN and non-TURN connectivity. 150 | config.mediasoup.webRtcTransportOptions.listenIps.push({ 151 | ip : '0.0.0.0', 152 | announcedIp : process.env.MEDIASOUP_ANNOUNCED_IP 153 | }); 154 | } 155 | 156 | module.exports = config; 157 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | const Logger = require('./Logger'); 2 | 3 | const logger = new Logger('utils'); 4 | 5 | const rooms = new Map(); 6 | 7 | /** 8 | * ccu threshold for creating new router 9 | */ 10 | let ccuThreshold = process.env.CCU_THRESHOLD?Number(process.env.CCU_THRESHOLD):50; 11 | logger.info("ccuThreshold: ", ccuThreshold) 12 | 13 | const workerLoadMan = (function() { 14 | /** 15 | * map 19 | * }> 20 | */ 21 | let _workerLoadMap = new Map(); //TODO: maintenance job for zombie rooms necessary? 22 | 23 | function resetWorkerLoadMap(){ 24 | for (let [pid, workerMeta] of _workerLoadMap.entries()){ 25 | workerMeta.peerCnt = 0; 26 | workerMeta.roomReqCnt = 0; 27 | workerMeta.rooms = new Map(); 28 | } 29 | } 30 | 31 | return { 32 | set: function(pid, workerMeta){ 33 | _workerLoadMap.set(pid, workerMeta); 34 | }, 35 | 36 | /** 37 | * returns _workerLoadMap 38 | */ 39 | get: function(){ 40 | return _workerLoadMap; 41 | }, 42 | 43 | /** 44 | * 45 | * @param {*} pid (string) worker._pid 46 | * @param {*} amt (int) amount to add, default=1 47 | */ 48 | addPeer: function(pid, amt=1){ 49 | const currentWorkerMeta = _workerLoadMap.get(pid); 50 | 51 | _workerLoadMap.set(pid, { 52 | peerCnt: currentWorkerMeta.peerCnt + amt, 53 | roomReqCnt: currentWorkerMeta.roomReqCnt, 54 | rooms: currentWorkerMeta.rooms 55 | }); 56 | }, 57 | 58 | /** 59 | * @param {*} pid (string) worker._pid 60 | * @param {*} amt (int) amount to add, default=9999999 61 | */ 62 | addRoomReq: function(pid, roomId, amt=9999999){ 63 | // logger.info("addRoomReq, pid: %s", pid) 64 | //roomReqCnt 65 | if (!_workerLoadMap.has(pid)) { 66 | logger.error("addRoomReq -- unexpected worker pid: %s", pid); 67 | _workerLoadMap.set(pid, { peerCnt: 0, roomReqCnt: 0, rooms: new Map() }); 68 | } 69 | const workerMeta = _workerLoadMap.get(pid); 70 | workerMeta.roomReqCnt += amt; 71 | 72 | //rooms 73 | if (!workerMeta.rooms.has(roomId)) { 74 | workerMeta.rooms.set(roomId, amt); 75 | } else { 76 | const newAmt = workerMeta.rooms.get(roomId) + amt; 77 | workerMeta.rooms.set(roomId, newAmt); 78 | } 79 | }, 80 | 81 | getLeastLoadedWorkerIdx: function(mediasoupWorkers, roomId, roomReq){ 82 | let minCnt_room = Number.MAX_VALUE; 83 | let minCnt_peer = Number.MAX_VALUE; 84 | let minWorkerIdx_room = -1; 85 | let minWorkerIdx_peer = -1; 86 | 87 | for (let [pid, workerMeta] of _workerLoadMap.entries()){ 88 | const idx = mediasoupWorkers.map((worker) => worker._pid).indexOf(pid); 89 | if (idx === -1){ continue; } 90 | 91 | // ignore the amount reserved for requesting room 92 | let roomReqCnt = workerMeta.rooms.has(roomId) ? workerMeta.roomReqCnt - roomReq : workerMeta.roomReqCnt; 93 | 94 | logger.info( 95 | "workerMeta.rooms: %s; roomId: %s; has?: %s, roomReqCnt: %s", 96 | workerMeta.rooms, roomId, workerMeta.rooms.has(roomId), roomReqCnt); 97 | 98 | if (roomReqCnt < minCnt_room){ 99 | minCnt_room = roomReqCnt; 100 | minWorkerIdx_room = idx; 101 | } 102 | 103 | if (workerMeta.peerCnt < minCnt_peer){ 104 | minCnt_peer = workerMeta.peerCnt; 105 | minWorkerIdx_peer = idx; 106 | } 107 | } 108 | 109 | logger.info( 110 | "minCnt_room: %s, workerIdx_room: %s, minCnt_peer: %s, workerIdx_peer: %s", 111 | minCnt_room, minWorkerIdx_room, minCnt_peer, minWorkerIdx_peer 112 | ); 113 | 114 | return minCnt_room > minCnt_peer ? [minWorkerIdx_room, minCnt_room] : [minWorkerIdx_peer, minCnt_peer]; 115 | }, 116 | 117 | sum: function(){ 118 | let result = 0; 119 | for (let [pid, workerMeta] of _workerLoadMap.entries()){ 120 | result += workerMeta.roomReqCnt > workerMeta.peerCnt ? workerMeta.roomReqCnt : workerMeta.peerCnt; 121 | } 122 | return result; 123 | }, 124 | 125 | runSurvey: function(){ 126 | resetWorkerLoadMap(); 127 | 128 | for (let [id, room] of rooms.entries()){ 129 | for (let peer of room.getPeers()){ 130 | this.addPeer(peer.data.workerPid); 131 | } 132 | 133 | for (let [worker, routerId] of room._inUseMediasoupWorkers){ 134 | this.addRoomReq(worker._pid, room._roomId, room._roomReq); 135 | } 136 | } 137 | // logger.info("runSurvey -- this._workerLoadMap: %s", JSON.stringify(this._workerLoadMap, stableSortReplacer, 2)); 138 | }, 139 | }; 140 | })(); 141 | 142 | const stableSortReplacer = (key, value) => { 143 | if (value instanceof Map) { 144 | const keysToObj = (obj, mapKey) => { 145 | obj[mapKey] = value.get(mapKey); 146 | return obj; 147 | }; 148 | return [...value.keys()].sort().reduce(keysToObj, {}); 149 | } else if (value instanceof Set) { 150 | return [...value].sort(); 151 | } 152 | return value; 153 | }; 154 | 155 | function serializer(replacer, cycleReplacer) { 156 | var stack = [], keys = [] 157 | 158 | if (cycleReplacer == null) cycleReplacer = function(key, value) { 159 | if (stack[0] === value) return "[Circular ~]" 160 | return "[Circular ~." + keys.slice(0, stack.indexOf(value)).join(".") + "]" 161 | } 162 | 163 | return function(key, value) { 164 | if (stack.length > 0) { 165 | var thisPos = stack.indexOf(this) 166 | ~thisPos ? stack.splice(thisPos + 1) : stack.push(this) 167 | ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key) 168 | if (~stack.indexOf(value)) value = cycleReplacer.call(this, key, value) 169 | } 170 | else stack.push(value) 171 | 172 | return replacer == null ? value : replacer.call(this, key, value) 173 | } 174 | } 175 | 176 | module.exports = { 177 | ccuThreshold: ccuThreshold, 178 | workerLoadMan: workerLoadMan, 179 | rooms: rooms, 180 | stableSortReplacer: stableSortReplacer, 181 | serializer: serializer 182 | }; 183 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | { 3 | env: 4 | { 5 | es6: true, 6 | node: true 7 | }, 8 | extends: 9 | [ 10 | 'eslint:recommended' 11 | ], 12 | settings: {}, 13 | parserOptions: 14 | { 15 | ecmaVersion: 2018, 16 | sourceType: 'module', 17 | ecmaFeatures: 18 | { 19 | impliedStrict: true 20 | } 21 | }, 22 | rules: 23 | { 24 | 'array-bracket-spacing': [ 2, 'always', 25 | { 26 | objectsInArrays: true, 27 | arraysInArrays: true 28 | }], 29 | 'arrow-parens': [ 2, 'always' ], 30 | 'arrow-spacing': 2, 31 | 'block-spacing': [ 2, 'always' ], 32 | 'brace-style': [ 2, 'allman', { allowSingleLine: true } ], 33 | 'camelcase': 2, 34 | 'comma-dangle': 2, 35 | 'comma-spacing': [ 2, { before: false, after: true } ], 36 | 'comma-style': 2, 37 | 'computed-property-spacing': 2, 38 | 'constructor-super': 2, 39 | 'func-call-spacing': 2, 40 | 'generator-star-spacing': 2, 41 | 'guard-for-in': 2, 42 | 'indent': [ 2, 'tab', { 'SwitchCase': 1 } ], 43 | 'key-spacing': [ 2, 44 | { 45 | singleLine: 46 | { 47 | beforeColon: false, 48 | afterColon: true 49 | }, 50 | multiLine: 51 | { 52 | beforeColon: true, 53 | afterColon: true, 54 | align: 'colon' 55 | } 56 | }], 57 | 'keyword-spacing': 2, 58 | 'linebreak-style': [ 2, 'unix' ], 59 | 'lines-around-comment': [ 2, 60 | { 61 | allowBlockStart: true, 62 | allowObjectStart: true, 63 | beforeBlockComment: true, 64 | beforeLineComment: false 65 | }], 66 | 'max-len': [ 2, 90, 67 | { 68 | tabWidth: 2, 69 | comments: 90, 70 | ignoreUrls: true, 71 | ignoreStrings: true, 72 | ignoreTemplateLiterals: true, 73 | ignoreRegExpLiterals: true 74 | }], 75 | 'newline-after-var': 2, 76 | 'newline-before-return': 2, 77 | 'newline-per-chained-call': 2, 78 | 'no-alert': 2, 79 | 'no-caller': 2, 80 | 'no-case-declarations': 2, 81 | 'no-catch-shadow': 2, 82 | 'no-class-assign': 2, 83 | 'no-confusing-arrow': 2, 84 | 'no-console': 2, 85 | 'no-const-assign': 2, 86 | 'no-debugger': 2, 87 | 'no-dupe-args': 2, 88 | 'no-dupe-keys': 2, 89 | 'no-duplicate-case': 2, 90 | 'no-div-regex': 2, 91 | 'no-empty': [ 2, { allowEmptyCatch: true } ], 92 | 'no-empty-pattern': 2, 93 | 'no-else-return': 0, 94 | 'no-eval': 2, 95 | 'no-extend-native': 2, 96 | 'no-ex-assign': 2, 97 | 'no-extra-bind': 2, 98 | 'no-extra-boolean-cast': 2, 99 | 'no-extra-label': 2, 100 | 'no-extra-semi': 2, 101 | 'no-fallthrough': 2, 102 | 'no-func-assign': 2, 103 | 'no-global-assign': 2, 104 | 'no-implicit-coercion': 2, 105 | 'no-implicit-globals': 2, 106 | 'no-inner-declarations': 2, 107 | 'no-invalid-regexp': 2, 108 | 'no-invalid-this': 2, 109 | 'no-irregular-whitespace': 2, 110 | 'no-lonely-if': 2, 111 | 'no-mixed-operators': 2, 112 | 'no-mixed-spaces-and-tabs': 2, 113 | 'no-multi-spaces': 2, 114 | 'no-multi-str': 2, 115 | 'no-multiple-empty-lines': [ 1, { max: 1, maxEOF: 0, maxBOF: 0 } ], 116 | 'no-native-reassign': 2, 117 | 'no-negated-in-lhs': 2, 118 | 'no-new': 2, 119 | 'no-new-func': 2, 120 | 'no-new-wrappers': 2, 121 | 'no-obj-calls': 2, 122 | 'no-proto': 2, 123 | 'no-prototype-builtins': 0, 124 | 'no-redeclare': 2, 125 | 'no-regex-spaces': 2, 126 | 'no-restricted-imports': 2, 127 | 'no-return-assign': 2, 128 | 'no-self-assign': 2, 129 | 'no-self-compare': 2, 130 | 'no-sequences': 2, 131 | 'no-shadow': 2, 132 | 'no-shadow-restricted-names': 2, 133 | 'no-spaced-func': 2, 134 | 'no-sparse-arrays': 2, 135 | 'no-this-before-super': 2, 136 | 'no-throw-literal': 2, 137 | 'no-undef': 2, 138 | 'no-unexpected-multiline': 2, 139 | 'no-unmodified-loop-condition': 2, 140 | 'no-unreachable': 2, 141 | 'no-unused-vars': [ 1, { vars: 'all', args: 'after-used' }], 142 | 'no-use-before-define': [ 2, { functions: false } ], 143 | 'no-useless-call': 2, 144 | 'no-useless-computed-key': 2, 145 | 'no-useless-concat': 2, 146 | 'no-useless-rename': 2, 147 | 'no-var': 2, 148 | 'no-whitespace-before-property': 2, 149 | 'object-curly-newline': 0, 150 | 'object-curly-spacing': [ 2, 'always' ], 151 | 'object-property-newline': [ 2, { allowMultiplePropertiesPerLine: true } ], 152 | 'prefer-const': 2, 153 | 'prefer-rest-params': 2, 154 | 'prefer-spread': 2, 155 | 'prefer-template': 2, 156 | 'quotes': [ 2, 'single', { avoidEscape: true } ], 157 | 'semi': [ 2, 'always' ], 158 | 'semi-spacing': 2, 159 | 'space-before-blocks': 2, 160 | 'space-before-function-paren': [ 2, 161 | { 162 | anonymous : 'never', 163 | named : 'never', 164 | asyncArrow : 'always' 165 | }], 166 | 'space-in-parens': [ 2, 'never' ], 167 | 'spaced-comment': [ 2, 'always' ], 168 | 'strict': 2, 169 | 'valid-typeof': 2, 170 | 'yoda': 2 171 | } 172 | }; 173 | 174 | switch (process.env.MEDIASOUP_NODE_LANGUAGE) 175 | { 176 | case 'typescript': 177 | { 178 | eslintConfig.parser = '@typescript-eslint/parser'; 179 | eslintConfig.plugins = 180 | [ 181 | ...eslintConfig.plugins, 182 | '@typescript-eslint' 183 | ]; 184 | eslintConfig.extends = 185 | [ 186 | 'eslint:recommended', 187 | 'plugin:@typescript-eslint/eslint-recommended', 188 | 'plugin:@typescript-eslint/recommended' 189 | ]; 190 | eslintConfig.rules = 191 | { 192 | ...eslintConfig.rules, 193 | 'no-unused-vars' : 0, 194 | '@typescript-eslint/ban-ts-ignore' : 0, 195 | '@typescript-eslint/member-delimiter-style' : [ 2, 196 | { 197 | multiline : { delimiter: 'semi', requireLast: true }, 198 | singleline : { delimiter: 'semi', requireLast: false } 199 | } 200 | ], 201 | '@typescript-eslint/no-explicit-any' : 0, 202 | '@typescript-eslint/no-unused-vars' : [ 2, 203 | { 204 | vars : 'all', 205 | args : 'after-used', 206 | ignoreRestSiblings : false 207 | } 208 | ], 209 | '@typescript-eslint/no-use-before-define' : 0, 210 | '@typescript-eslint/no-empty-function' : 0 211 | }; 212 | 213 | break; 214 | } 215 | 216 | case 'javascript': 217 | { 218 | eslintConfig.env['jest/globals'] = true; 219 | eslintConfig.plugins = 220 | [ 221 | ...eslintConfig.plugins, 222 | 'jest' 223 | ]; 224 | 225 | break; 226 | } 227 | 228 | default: 229 | { 230 | throw new TypeError('wrong/missing MEDIASOUP_NODE_LANGUAGE env'); 231 | } 232 | } 233 | 234 | module.exports = eslintConfig; 235 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Based upon mediasoup-demo server 4 | // https://github.com/versatica/mediasoup-demo/tree/v3/server 5 | 6 | process.title = 'dialog'; 7 | process.env.DEBUG = process.env.DEBUG || '*INFO* *WARN* *ERROR*'; 8 | 9 | const config = require('./config'); 10 | 11 | /* eslint-disable no-console */ 12 | console.log('process.env.DEBUG:', process.env.DEBUG); 13 | console.log('config.js:\n%s', JSON.stringify(config, null, ' ')); 14 | /* eslint-enable no-console */ 15 | 16 | const fs = require('fs'); 17 | const https = require('https'); 18 | const http = require('http'); 19 | const url = require('url'); 20 | const protoo = require('protoo-server'); 21 | const mediasoup = require('mediasoup'); 22 | const express = require('express'); 23 | const bodyParser = require('body-parser'); 24 | const { AwaitQueue } = require('awaitqueue'); 25 | const Logger = require('./lib/Logger'); 26 | const Room = require('./lib/Room'); 27 | const interactiveServer = require('./lib/interactiveServer'); 28 | const interactiveClient = require('./lib/interactiveClient'); 29 | const util = require('util'); 30 | const readFile = util.promisify(fs.readFile); 31 | 32 | const utils = require('./lib/utils'); 33 | 34 | const os = require('os'); 35 | 36 | const logger = new Logger(); 37 | const queue = new AwaitQueue(); 38 | const rooms = utils.rooms; 39 | 40 | let httpsServer; 41 | 42 | let expressApp; 43 | let expressAdminApp; 44 | let protooWebSocketServer; 45 | 46 | const mediasoupWorkers = []; 47 | 48 | let authKey; 49 | 50 | run(); 51 | 52 | async function run() 53 | { 54 | await interactiveServer(); 55 | 56 | if (process.env.INTERACTIVE === 'true' || process.env.INTERACTIVE === '1') 57 | await interactiveClient(); 58 | 59 | await runMediasoupWorkers(); 60 | await createExpressApp(); 61 | await createAdminExpressApp(); 62 | await runHttpsServer(); 63 | try { 64 | authKey = await readFile(config.authKey, 'utf8'); 65 | } catch (error) { 66 | logger.error("authKey not set; jwt verification will not work.", error); 67 | } 68 | await runProtooWebSocketServer(); 69 | 70 | 71 | // Log rooms status every X seconds. 72 | setInterval(() => 73 | { 74 | for (const room of rooms.values()) 75 | { 76 | room.logStatus(); 77 | } 78 | }, 900000); 79 | } 80 | 81 | async function runMediasoupWorkers() 82 | { 83 | const { numWorkers } = config.mediasoup; 84 | 85 | logger.info('running %d mediasoup Workers...', numWorkers); 86 | 87 | for (let i = 0; i < numWorkers; ++i) 88 | { 89 | const worker = await mediasoup.createWorker( 90 | { 91 | logLevel : config.mediasoup.workerSettings.logLevel, 92 | logTags : config.mediasoup.workerSettings.logTags, 93 | rtcMinPort : Number(config.mediasoup.workerSettings.rtcMinPort), 94 | rtcMaxPort : Number(config.mediasoup.workerSettings.rtcMaxPort) 95 | }); 96 | 97 | worker.on('died', () => 98 | { 99 | logger.error( 100 | 'mediasoup Worker died, exiting in 2 seconds... [pid:%d]', worker.pid); 101 | 102 | setTimeout(() => process.exit(1), 2000); 103 | }); 104 | 105 | mediasoupWorkers.push(worker); 106 | utils.workerLoadMan.set(worker._pid, { peerCnt: 0, roomReqCnt: 0, rooms: new Map() }); 107 | } 108 | 109 | utils.workerLoadMan.runSurvey(); 110 | 111 | setInterval(async () => { 112 | const startTimestampNs = process.hrtime.bigint(); 113 | utils.workerLoadMan.runSurvey(); 114 | const elapsedMs = Number(process.hrtime.bigint() - startTimestampNs) / 1000000; 115 | if (elapsedMs > 0.1) { logger.warn('runSurvey() took: %s ms', elapsedMs); } 116 | }, 5000); 117 | } 118 | 119 | async function createExpressApp() 120 | { 121 | logger.info('creating Express app...'); 122 | 123 | expressApp = express(); 124 | expressApp.use(bodyParser.json()); 125 | 126 | expressApp.param( 127 | 'roomId', (req, res, next, roomId) => 128 | { 129 | if (!rooms.has(roomId)) 130 | { 131 | const error = new Error(`room with id "${roomId}" not found`); 132 | 133 | error.status = 404; 134 | throw error; 135 | } 136 | 137 | req.room = rooms.get(roomId); 138 | 139 | next(); 140 | }); 141 | 142 | /** 143 | * Error handler. 144 | */ 145 | expressApp.use( 146 | (error, req, res, next) => 147 | { 148 | if (error) 149 | { 150 | logger.warn('Express app %s', String(error)); 151 | 152 | error.status = error.status || (error.name === 'TypeError' ? 400 : 500); 153 | 154 | res.statusMessage = error.message; 155 | res.status(error.status).send(String(error)); 156 | } 157 | else 158 | { 159 | next(); 160 | } 161 | }); 162 | } 163 | 164 | async function createAdminExpressApp() 165 | { 166 | logger.info('creating Admin Express app...'); 167 | 168 | expressAdminApp = express(); 169 | expressAdminApp.use(bodyParser.json()); 170 | 171 | /** 172 | * Temporary deprecated API to emulate Janus endpoint to get CCU by reticulum. 173 | */ 174 | expressAdminApp.post( 175 | '/admin', (req, res) => 176 | { 177 | const sessions = []; 178 | 179 | for (const room in rooms.values()) { 180 | for (let i = 0; i < room.getCCU(); i++) { 181 | sessions.push({}); 182 | } 183 | } 184 | 185 | res.status(200).json({ sessions }); 186 | }); 187 | 188 | /** 189 | * meta API to report current capacity 190 | */ 191 | expressAdminApp.get( 192 | '/meta', (req, res) => 193 | { 194 | res.status(200).json({ 195 | cap: utils.workerLoadMan.sum(), 196 | // ip: process.env.MEDIASOUP_ANNOUNCED_IP 197 | }); 198 | }); 199 | 200 | /** 201 | * full report 202 | */ 203 | expressAdminApp.get( 204 | '/report', (req, res) => 205 | { 206 | const report = new Map(utils.workerLoadMan.get()); 207 | report.set('_hostname', os.hostname()); 208 | report.set('_capacity', utils.workerLoadMan.sum()); 209 | res.set({ 'Content-Type': 'application/json' }) 210 | .status(200) 211 | .send(JSON.stringify(report, utils.stableSortReplacer, 2)); 212 | }); 213 | /** 214 | * dump room 215 | */ 216 | expressAdminApp.get( 217 | '/report/rooms/:roomId', (req, res) => 218 | { 219 | const room = rooms.get(req.params.roomId); 220 | console.log(room) 221 | res.set({ 'Content-Type': 'application/json' }) 222 | .status(200) 223 | .send(room); 224 | }); 225 | /** 226 | * dump peer 227 | */ 228 | expressAdminApp.get( 229 | '/report/peers/:peerId', (req, res) => 230 | { 231 | const peerId = req.params.peerId 232 | let room = {} 233 | for (const [k,v] of rooms.entries()){ 234 | if (v._protooRoom.hasPeer(peerId)){ 235 | room =v 236 | } 237 | } 238 | 239 | const peer = room._protooRoom.getPeer(peerId); 240 | console.log(peer) 241 | res.set({ 'Content-Type': 'application/json' }) 242 | .status(200) 243 | .send(peer._data); 244 | }); 245 | 246 | /** 247 | * Error handler. 248 | */ 249 | expressAdminApp.use( 250 | (error, req, res, next) => 251 | { 252 | if (error) 253 | { 254 | logger.warn('Express app %s', String(error)); 255 | 256 | error.status = error.status || (error.name === 'TypeError' ? 400 : 500); 257 | 258 | res.statusMessage = error.message; 259 | res.status(error.status).send(String(error)); 260 | } 261 | else 262 | { 263 | next(); 264 | } 265 | }); 266 | } 267 | 268 | /** 269 | * Create a Node.js HTTPS server. It listens in the IP and port given in the 270 | * configuration file and reuses the Express application as request listener. 271 | */ 272 | async function runHttpsServer() 273 | { 274 | logger.info('running an HTTPS server...'); 275 | 276 | // HTTPS server for the protoo WebSocket server. 277 | const tls = 278 | { 279 | cert : fs.readFileSync(config.https.tls.cert), 280 | key : fs.readFileSync(config.https.tls.key) 281 | }; 282 | 283 | httpsServer = https.createServer(tls, expressApp); 284 | 285 | await new Promise((resolve) => 286 | { 287 | httpsServer.listen( 288 | Number(config.https.listenPort), config.https.listenIp, resolve); 289 | }); 290 | 291 | // TODO remove, alt server needed to spoof janus API. 292 | logger.info('running an Admin HTTP server...'); 293 | 294 | adminHttpServer = http.createServer(expressAdminApp); 295 | 296 | await new Promise((resolve) => 297 | { 298 | adminHttpServer.listen( 299 | Number(config.adminHttp.listenPort), config.adminHttp.listenIp, resolve); 300 | }); 301 | } 302 | 303 | 304 | /** 305 | * Create a protoo WebSocketServer to allow WebSocket connections from browsers. 306 | */ 307 | async function runProtooWebSocketServer() 308 | { 309 | logger.info('running protoo WebSocketServer...'); 310 | 311 | // Create the protoo WebSocket server. 312 | protooWebSocketServer = new protoo.WebSocketServer(httpsServer, 313 | { 314 | maxReceivedFrameSize : 960000, // 960 KBytes. 315 | maxReceivedMessageSize : 960000, 316 | fragmentOutgoingMessages : true, 317 | fragmentationThreshold : 960000 318 | }); 319 | 320 | // Handle connections from clients. 321 | protooWebSocketServer.on('connectionrequest', (info, accept, reject) => 322 | { 323 | // The client indicates the roomId and peerId in the URL query. 324 | const u = url.parse(info.request.url, true); 325 | const roomId = u.query['roomId']; 326 | const peerId = u.query['peerId']; 327 | if (!roomId || !peerId) 328 | { 329 | reject(400, 'Connection request without roomId and/or peerId'); 330 | 331 | return; 332 | } 333 | 334 | logger.info( 335 | 'protoo connection request [roomId:%s, peerId:%s, address:%s, origin:%s]', 336 | roomId, peerId, info.socket.remoteAddress, info.origin); 337 | const roomSize = info.request.headers['x-ret-max-room-size']; 338 | logger.info('roomId: %s, x-ret-max-room-size: %s', roomId, roomSize); 339 | 340 | // Serialize this code into the queue to avoid that two peers connecting at 341 | // the same time with the same roomId create two separate rooms with same 342 | // roomId. 343 | queue.push(async () => 344 | { 345 | const room = await getOrCreateRoom({ roomId, roomSize }); 346 | 347 | // Accept the protoo WebSocket connection. 348 | const protooWebSocketTransport = accept(); 349 | 350 | room.handleProtooConnection({ peerId, protooWebSocketTransport }); 351 | }) 352 | .catch((error) => 353 | { 354 | logger.error('room creation or room joining failed:%o', error); 355 | 356 | reject(error); 357 | }); 358 | }); 359 | } 360 | 361 | async function getOrCreateRoom({ roomId, roomSize=0 }) 362 | { 363 | let room = rooms.get(roomId); 364 | 365 | // If the Room does not exist create a new one. 366 | if (!room) 367 | { 368 | logger.info('creating a new Room [roomId:%s]', roomId); 369 | 370 | room = await Room.create({ mediasoupWorkers, roomId, authKey, roomSize }); 371 | 372 | rooms.set(roomId, room); 373 | room.on('close', () => rooms.delete(roomId)); 374 | } 375 | 376 | return room; 377 | } 378 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /lib/interactiveServer.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const path = require('path'); 3 | const repl = require('repl'); 4 | const readline = require('readline'); 5 | const net = require('net'); 6 | const fs = require('fs'); 7 | const mediasoup = require('mediasoup'); 8 | const colors = require('colors/safe'); 9 | const pidusage = require('pidusage'); 10 | const heapdump = require('heapdump'); 11 | 12 | const SOCKET_PATH_UNIX = '/tmp/mediasoup-demo.sock'; 13 | const SOCKET_PATH_WIN = path.join('\\\\?\\pipe', process.cwd(), 'mediasoup-demo'); 14 | const SOCKET_PATH = os.platform() === 'win32' ? SOCKET_PATH_WIN : SOCKET_PATH_UNIX; 15 | 16 | // Maps to store all mediasoup objects. 17 | const workers = new Map(); 18 | const routers = new Map(); 19 | const transports = new Map(); 20 | const producers = new Map(); 21 | const consumers = new Map(); 22 | const dataProducers = new Map(); 23 | const dataConsumers = new Map(); 24 | 25 | class Interactive 26 | { 27 | constructor(socket) 28 | { 29 | this._socket = socket; 30 | 31 | this._isTerminalOpen = false; 32 | } 33 | 34 | openCommandConsole() 35 | { 36 | this.log('\n[opening Readline Command Console...]'); 37 | this.log('type help to print available commands'); 38 | 39 | const cmd = readline.createInterface( 40 | { 41 | input : this._socket, 42 | output : this._socket, 43 | terminal : true 44 | }); 45 | 46 | cmd.on('close', () => 47 | { 48 | if (this._isTerminalOpen) 49 | return; 50 | 51 | this.log('\nexiting...'); 52 | 53 | this._socket.end(); 54 | }); 55 | 56 | const readStdin = () => 57 | { 58 | cmd.question('cmd> ', async (input) => 59 | { 60 | const params = input.split(/[\s\t]+/); 61 | const command = params.shift(); 62 | 63 | switch (command) 64 | { 65 | case '': 66 | { 67 | readStdin(); 68 | break; 69 | } 70 | 71 | case 'h': 72 | case 'help': 73 | { 74 | this.log(''); 75 | this.log('available commands:'); 76 | this.log('- h, help : show this message'); 77 | this.log('- usage : show CPU and memory usage of the Node.js and mediasoup-worker processes'); 78 | this.log('- logLevel level : changes logLevel in all mediasoup Workers'); 79 | this.log('- logTags [tag] [tag] : changes logTags in all mediasoup Workers (values separated by space)'); 80 | this.log('- dw, dumpWorkers : dump mediasoup Workers'); 81 | this.log('- dr, dumpRouter [id] : dump mediasoup Router with given id (or the latest created one)'); 82 | this.log('- dt, dumpTransport [id] : dump mediasoup Transport with given id (or the latest created one)'); 83 | this.log('- dp, dumpProducer [id] : dump mediasoup Producer with given id (or the latest created one)'); 84 | this.log('- dc, dumpConsumer [id] : dump mediasoup Consumer with given id (or the latest created one)'); 85 | this.log('- ddp, dumpDataProducer [id] : dump mediasoup DataProducer with given id (or the latest created one)'); 86 | this.log('- ddc, dumpDataConsumer [id] : dump mediasoup DataConsumer with given id (or the latest created one)'); 87 | this.log('- st, statsTransport [id] : get stats for mediasoup Transport with given id (or the latest created one)'); 88 | this.log('- sp, statsProducer [id] : get stats for mediasoup Producer with given id (or the latest created one)'); 89 | this.log('- sc, statsConsumer [id] : get stats for mediasoup Consumer with given id (or the latest created one)'); 90 | this.log('- sdp, statsDataProducer [id] : get stats for mediasoup DataProducer with given id (or the latest created one)'); 91 | this.log('- sdc, statsDataConsumer [id] : get stats for mediasoup DataConsumer with given id (or the latest created one)'); 92 | this.log('- hs, heapsnapshot : write a heapdump snapshot to file'); 93 | this.log('- t, terminal : open Node REPL Terminal'); 94 | this.log(''); 95 | readStdin(); 96 | 97 | break; 98 | } 99 | 100 | case 'u': 101 | case 'usage': 102 | { 103 | let usage = await pidusage(process.pid); 104 | 105 | this.log(`Node.js process [pid:${process.pid}]:\n${JSON.stringify(usage, null, ' ')}`); 106 | 107 | for (const worker of workers.values()) 108 | { 109 | usage = await pidusage(worker.pid); 110 | 111 | this.log(`mediasoup-worker process [pid:${worker.pid}]:\n${JSON.stringify(usage, null, ' ')}`); 112 | } 113 | 114 | break; 115 | } 116 | 117 | case 'logLevel': 118 | { 119 | const level = params[0]; 120 | const promises = []; 121 | 122 | for (const worker of workers.values()) 123 | { 124 | promises.push(worker.updateSettings({ logLevel: level })); 125 | } 126 | 127 | try 128 | { 129 | await Promise.all(promises); 130 | 131 | this.log('done'); 132 | } 133 | catch (error) 134 | { 135 | this.error(String(error)); 136 | } 137 | 138 | break; 139 | } 140 | 141 | case 'logTags': 142 | { 143 | const tags = params; 144 | const promises = []; 145 | 146 | for (const worker of workers.values()) 147 | { 148 | promises.push(worker.updateSettings({ logTags: tags })); 149 | } 150 | 151 | try 152 | { 153 | await Promise.all(promises); 154 | 155 | this.log('done'); 156 | } 157 | catch (error) 158 | { 159 | this.error(String(error)); 160 | } 161 | 162 | break; 163 | } 164 | 165 | case 'dw': 166 | case 'dumpWorkers': 167 | { 168 | for (const worker of workers.values()) 169 | { 170 | try 171 | { 172 | const dump = await worker.dump(); 173 | 174 | this.log(`worker.dump():\n${JSON.stringify(dump, null, ' ')}`); 175 | } 176 | catch (error) 177 | { 178 | this.error(`worker.dump() failed: ${error}`); 179 | } 180 | } 181 | 182 | break; 183 | } 184 | 185 | case 'dr': 186 | case 'dumpRouter': 187 | { 188 | const id = params[0] || Array.from(routers.keys()).pop(); 189 | const router = routers.get(id); 190 | 191 | if (!router) 192 | { 193 | this.error('Router not found'); 194 | 195 | break; 196 | } 197 | 198 | try 199 | { 200 | const dump = await router.dump(); 201 | 202 | this.log(`router.dump():\n${JSON.stringify(dump, null, ' ')}`); 203 | } 204 | catch (error) 205 | { 206 | this.error(`router.dump() failed: ${error}`); 207 | } 208 | 209 | break; 210 | } 211 | 212 | case 'dt': 213 | case 'dumpTransport': 214 | { 215 | const id = params[0] || Array.from(transports.keys()).pop(); 216 | const transport = transports.get(id); 217 | 218 | if (!transport) 219 | { 220 | this.error('Transport not found'); 221 | 222 | break; 223 | } 224 | 225 | try 226 | { 227 | const dump = await transport.dump(); 228 | 229 | this.log(`transport.dump():\n${JSON.stringify(dump, null, ' ')}`); 230 | } 231 | catch (error) 232 | { 233 | this.error(`transport.dump() failed: ${error}`); 234 | } 235 | 236 | break; 237 | } 238 | 239 | case 'dp': 240 | case 'dumpProducer': 241 | { 242 | const id = params[0] || Array.from(producers.keys()).pop(); 243 | const producer = producers.get(id); 244 | 245 | if (!producer) 246 | { 247 | this.error('Producer not found'); 248 | 249 | break; 250 | } 251 | 252 | try 253 | { 254 | const dump = await producer.dump(); 255 | 256 | this.log(`producer.dump():\n${JSON.stringify(dump, null, ' ')}`); 257 | } 258 | catch (error) 259 | { 260 | this.error(`producer.dump() failed: ${error}`); 261 | } 262 | 263 | break; 264 | } 265 | 266 | case 'dc': 267 | case 'dumpConsumer': 268 | { 269 | const id = params[0] || Array.from(consumers.keys()).pop(); 270 | const consumer = consumers.get(id); 271 | 272 | if (!consumer) 273 | { 274 | this.error('Consumer not found'); 275 | 276 | break; 277 | } 278 | 279 | try 280 | { 281 | const dump = await consumer.dump(); 282 | 283 | this.log(`consumer.dump():\n${JSON.stringify(dump, null, ' ')}`); 284 | } 285 | catch (error) 286 | { 287 | this.error(`consumer.dump() failed: ${error}`); 288 | } 289 | 290 | break; 291 | } 292 | 293 | case 'ddp': 294 | case 'dumpDataProducer': 295 | { 296 | const id = params[0] || Array.from(dataProducers.keys()).pop(); 297 | const dataProducer = dataProducers.get(id); 298 | 299 | if (!dataProducer) 300 | { 301 | this.error('DataProducer not found'); 302 | 303 | break; 304 | } 305 | 306 | try 307 | { 308 | const dump = await dataProducer.dump(); 309 | 310 | this.log(`dataProducer.dump():\n${JSON.stringify(dump, null, ' ')}`); 311 | } 312 | catch (error) 313 | { 314 | this.error(`dataProducer.dump() failed: ${error}`); 315 | } 316 | 317 | break; 318 | } 319 | 320 | case 'ddc': 321 | case 'dumpDataConsumer': 322 | { 323 | const id = params[0] || Array.from(dataConsumers.keys()).pop(); 324 | const dataConsumer = dataConsumers.get(id); 325 | 326 | if (!dataConsumer) 327 | { 328 | this.error('DataConsumer not found'); 329 | 330 | break; 331 | } 332 | 333 | try 334 | { 335 | const dump = await dataConsumer.dump(); 336 | 337 | this.log(`dataConsumer.dump():\n${JSON.stringify(dump, null, ' ')}`); 338 | } 339 | catch (error) 340 | { 341 | this.error(`dataConsumer.dump() failed: ${error}`); 342 | } 343 | 344 | break; 345 | } 346 | 347 | case 'st': 348 | case 'statsTransport': 349 | { 350 | const id = params[0] || Array.from(transports.keys()).pop(); 351 | const transport = transports.get(id); 352 | 353 | if (!transport) 354 | { 355 | this.error('Transport not found'); 356 | 357 | break; 358 | } 359 | 360 | try 361 | { 362 | const stats = await transport.getStats(); 363 | 364 | this.log(`transport.getStats():\n${JSON.stringify(stats, null, ' ')}`); 365 | } 366 | catch (error) 367 | { 368 | this.error(`transport.getStats() failed: ${error}`); 369 | } 370 | 371 | break; 372 | } 373 | 374 | case 'sp': 375 | case 'statsProducer': 376 | { 377 | const id = params[0] || Array.from(producers.keys()).pop(); 378 | const producer = producers.get(id); 379 | 380 | if (!producer) 381 | { 382 | this.error('Producer not found'); 383 | 384 | break; 385 | } 386 | 387 | try 388 | { 389 | const stats = await producer.getStats(); 390 | 391 | this.log(`producer.getStats():\n${JSON.stringify(stats, null, ' ')}`); 392 | } 393 | catch (error) 394 | { 395 | this.error(`producer.getStats() failed: ${error}`); 396 | } 397 | 398 | break; 399 | } 400 | 401 | case 'sc': 402 | case 'statsConsumer': 403 | { 404 | const id = params[0] || Array.from(consumers.keys()).pop(); 405 | const consumer = consumers.get(id); 406 | 407 | if (!consumer) 408 | { 409 | this.error('Consumer not found'); 410 | 411 | break; 412 | } 413 | 414 | try 415 | { 416 | const stats = await consumer.getStats(); 417 | 418 | this.log(`consumer.getStats():\n${JSON.stringify(stats, null, ' ')}`); 419 | } 420 | catch (error) 421 | { 422 | this.error(`consumer.getStats() failed: ${error}`); 423 | } 424 | 425 | break; 426 | } 427 | 428 | case 'sdp': 429 | case 'statsDataProducer': 430 | { 431 | const id = params[0] || Array.from(dataProducers.keys()).pop(); 432 | const dataProducer = dataProducers.get(id); 433 | 434 | if (!dataProducer) 435 | { 436 | this.error('DataProducer not found'); 437 | 438 | break; 439 | } 440 | 441 | try 442 | { 443 | const stats = await dataProducer.getStats(); 444 | 445 | this.log(`dataProducer.getStats():\n${JSON.stringify(stats, null, ' ')}`); 446 | } 447 | catch (error) 448 | { 449 | this.error(`dataProducer.getStats() failed: ${error}`); 450 | } 451 | 452 | break; 453 | } 454 | 455 | case 'sdc': 456 | case 'statsDataConsumer': 457 | { 458 | const id = params[0] || Array.from(dataConsumers.keys()).pop(); 459 | const dataConsumer = dataConsumers.get(id); 460 | 461 | if (!dataConsumer) 462 | { 463 | this.error('DataConsumer not found'); 464 | 465 | break; 466 | } 467 | 468 | try 469 | { 470 | const stats = await dataConsumer.getStats(); 471 | 472 | this.log(`dataConsumer.getStats():\n${JSON.stringify(stats, null, ' ')}`); 473 | } 474 | catch (error) 475 | { 476 | this.error(`dataConsumer.getStats() failed: ${error}`); 477 | } 478 | 479 | break; 480 | } 481 | 482 | case 'hs': 483 | case 'heapsnapshot': 484 | { 485 | const filename = 486 | `${process.env.SNAPSHOT_DIR || '/tmp'}/${Date.now()}-mediasoup-demo.heapsnapshot`; 487 | 488 | // eslint-disable-next-line no-shadow 489 | heapdump.writeSnapshot(filename, (error, filename) => 490 | { 491 | if (!error) 492 | { 493 | this.log(`heapdump snapshot writen to ${filename}`); 494 | this.log( 495 | 'learn how to use it at https://github.com/bnoordhuis/node-heapdump'); 496 | } 497 | else 498 | { 499 | this.error(`heapdump snapshot failed: ${error}`); 500 | } 501 | }); 502 | 503 | break; 504 | } 505 | 506 | case 't': 507 | case 'terminal': 508 | { 509 | this._isTerminalOpen = true; 510 | 511 | cmd.close(); 512 | this.openTerminal(); 513 | 514 | return; 515 | } 516 | 517 | default: 518 | { 519 | this.error(`unknown command '${command}'`); 520 | this.log('press \'h\' or \'help\' to get the list of available commands'); 521 | } 522 | } 523 | 524 | readStdin(); 525 | }); 526 | }; 527 | 528 | readStdin(); 529 | } 530 | 531 | openTerminal() 532 | { 533 | this.log('\n[opening Node REPL Terminal...]'); 534 | this.log('here you have access to workers, routers, transports, producers, consumers, dataProducers and dataConsumers ES6 maps'); 535 | 536 | const terminal = repl.start( 537 | { 538 | input : this._socket, 539 | output : this._socket, 540 | terminal : true, 541 | prompt : 'terminal> ', 542 | useColors : true, 543 | useGlobal : true, 544 | ignoreUndefined : false 545 | }); 546 | 547 | this._isTerminalOpen = true; 548 | 549 | terminal.on('exit', () => 550 | { 551 | this.log('\n[exiting Node REPL Terminal...]'); 552 | 553 | this._isTerminalOpen = false; 554 | 555 | this.openCommandConsole(); 556 | }); 557 | } 558 | 559 | log(msg) 560 | { 561 | this._socket.write(`${colors.green(msg)}\n`); 562 | } 563 | 564 | error(msg) 565 | { 566 | this._socket.write(`${colors.red.bold('ERROR: ')}${colors.red(msg)}\n`); 567 | } 568 | } 569 | 570 | function runMediasoupObserver() 571 | { 572 | mediasoup.observer.on('newworker', (worker) => 573 | { 574 | // Store the latest worker in a global variable. 575 | global.worker = worker; 576 | 577 | workers.set(worker.pid, worker); 578 | worker.observer.on('close', () => workers.delete(worker.pid)); 579 | 580 | worker.observer.on('newrouter', (router) => 581 | { 582 | // Store the latest router in a global variable. 583 | global.router = router; 584 | 585 | routers.set(router.id, router); 586 | router.observer.on('close', () => routers.delete(router.id)); 587 | 588 | router.observer.on('newtransport', (transport) => 589 | { 590 | // Store the latest transport in a global variable. 591 | global.transport = transport; 592 | 593 | transports.set(transport.id, transport); 594 | transport.observer.on('close', () => transports.delete(transport.id)); 595 | 596 | transport.observer.on('newproducer', (producer) => 597 | { 598 | // Store the latest producer in a global variable. 599 | global.producer = producer; 600 | 601 | producers.set(producer.id, producer); 602 | producer.observer.on('close', () => producers.delete(producer.id)); 603 | }); 604 | 605 | transport.observer.on('newconsumer', (consumer) => 606 | { 607 | // Store the latest consumer in a global variable. 608 | global.consumer = consumer; 609 | 610 | consumers.set(consumer.id, consumer); 611 | consumer.observer.on('close', () => consumers.delete(consumer.id)); 612 | }); 613 | 614 | transport.observer.on('newdataproducer', (dataProducer) => 615 | { 616 | // Store the latest dataProducer in a global variable. 617 | global.dataProducer = dataProducer; 618 | 619 | dataProducers.set(dataProducer.id, dataProducer); 620 | dataProducer.observer.on('close', () => dataProducers.delete(dataProducer.id)); 621 | }); 622 | 623 | transport.observer.on('newdataconsumer', (dataConsumer) => 624 | { 625 | // Store the latest dataConsumer in a global variable. 626 | global.dataConsumer = dataConsumer; 627 | 628 | dataConsumers.set(dataConsumer.id, dataConsumer); 629 | dataConsumer.observer.on('close', () => dataConsumers.delete(dataConsumer.id)); 630 | }); 631 | }); 632 | }); 633 | }); 634 | } 635 | 636 | module.exports = async function() 637 | { 638 | // Run the mediasoup observer API. 639 | runMediasoupObserver(); 640 | 641 | // Make maps global so they can be used during the REPL terminal. 642 | global.workers = workers; 643 | global.routers = routers; 644 | global.transports = transports; 645 | global.producers = producers; 646 | global.consumers = consumers; 647 | global.dataProducers = dataProducers; 648 | global.dataConsumers = dataConsumers; 649 | 650 | const server = net.createServer((socket) => 651 | { 652 | const interactive = new Interactive(socket); 653 | 654 | interactive.openCommandConsole(); 655 | }); 656 | 657 | await new Promise((resolve) => 658 | { 659 | try { fs.unlinkSync(SOCKET_PATH); } 660 | catch (error) {} 661 | 662 | server.listen(SOCKET_PATH, resolve); 663 | }); 664 | }; 665 | -------------------------------------------------------------------------------- /lib/Room.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events').EventEmitter; 2 | const protoo = require('protoo-server'); 3 | const throttle = require('@sitespeed.io/throttle'); 4 | const Logger = require('./Logger'); 5 | const config = require('../config'); 6 | const jwt = require('jsonwebtoken'); 7 | 8 | const logger = new Logger('Room'); 9 | 10 | const utils = require('./utils'); 11 | 12 | 13 | /** 14 | * Room class. 15 | * 16 | * This is not a "mediasoup Room" by itself, by a custom class that holds 17 | * a protoo Room (for signaling with WebSocket clients) and a mediasoup Router 18 | * (for sending and receiving media to/from those WebSocket peers). 19 | */ 20 | class Room extends EventEmitter 21 | { 22 | /** 23 | * Factory function that creates and returns Room instance. 24 | * 25 | * @async 26 | * 27 | * @param {String} roomId - Id of the Room instance. 28 | */ 29 | static async create({ mediasoupWorkers, roomId, authKey, roomSize=0 }) 30 | { 31 | let roomReq = 0 32 | if (process.env.FORCE_ROOM_REQ) { 33 | roomReq = Number(process.env.FORCE_ROOM_REQ) 34 | logger.info("create()[roomId:%s] roomSize: %s, roomReq %s (FORCE_ROOM_REQ)", roomId, roomSize, roomReq); 35 | } else { 36 | // "best effort" guessing with pareto distribution / square root law for capacity reservation 37 | roomReq = Math.floor(Math.sqrt(roomSize)); 38 | logger.info("create()[roomId:%s] roomSize: %s, roomReq %s (pareto)", roomId, roomSize, roomReq); 39 | } 40 | const inUseMediasoupWorkers = new Map(); 41 | const mediasoupRouters = new Map(); 42 | 43 | // Create a protoo Room instance. 44 | const protooRoom = new protoo.Room(); 45 | 46 | // Router media codecs. 47 | const { mediaCodecs } = config.mediasoup.routerOptions; 48 | 49 | // Create first mediasoup Router on least loaded worker 50 | const [workerIdx, peerCnt] = utils.workerLoadMan.getLeastLoadedWorkerIdx(mediasoupWorkers, roomId, roomReq); 51 | const worker = mediasoupWorkers[workerIdx]; 52 | const router = await worker.createRouter({ mediaCodecs }); 53 | mediasoupRouters.set(router.id, router); 54 | inUseMediasoupWorkers.set(worker, router.id); 55 | 56 | const arr_unusedMediasoupWorkers = mediasoupWorkers.slice(); 57 | arr_unusedMediasoupWorkers.splice(arr_unusedMediasoupWorkers.indexOf(worker), 1); 58 | 59 | // Create a mediasoup AudioLevelObserver. 60 | const audioLevelObserver = await router.createAudioLevelObserver( 61 | { 62 | maxEntries : 1, 63 | threshold : -80, 64 | interval : 800 65 | }); 66 | 67 | return new Room( 68 | { 69 | roomId, 70 | roomReq, 71 | protooRoom, 72 | mediasoupRouters, 73 | audioLevelObserver, 74 | arr_unusedMediasoupWorkers, 75 | inUseMediasoupWorkers, 76 | authKey 77 | }); 78 | } 79 | 80 | constructor({ roomId, roomReq, protooRoom, mediasoupRouters, audioLevelObserver, arr_unusedMediasoupWorkers, inUseMediasoupWorkers, authKey }) 81 | { 82 | super(); 83 | this.setMaxListeners(Infinity); 84 | 85 | // Room id. 86 | // @type {String} 87 | this._roomId = roomId; 88 | 89 | this._roomReq = roomReq; 90 | 91 | // Closed flag. 92 | // @type {Boolean} 93 | this._closed = false; 94 | 95 | // protoo Room instance. 96 | // @type {protoo.Room} 97 | this._protooRoom = protooRoom; 98 | 99 | // {array} unused mediasoupWorkers for this room 100 | this._arr_unusedMediasoupWorkers = arr_unusedMediasoupWorkers; 101 | 102 | // {map} 103 | this._inUseMediasoupWorkers = inUseMediasoupWorkers; 104 | 105 | // Map of mediasoup Router instances. 106 | // {map} 107 | this._mediasoupRouters = mediasoupRouters; 108 | 109 | // mediasoup AudioLevelObserver. 110 | // @type {mediasoup.AudioLevelObserver} 111 | this._audioLevelObserver = audioLevelObserver; 112 | 113 | // Network throttled. 114 | // @type {Boolean} 115 | this._networkThrottled = false; 116 | 117 | this._authKey = authKey; 118 | 119 | // Handle audioLevelObserver. 120 | this._handleAudioLevelObserver(); 121 | 122 | // For debugging. 123 | global.audioLevelObserver = this._audioLevelObserver; 124 | } 125 | 126 | /** 127 | * Closes the Room instance by closing the protoo Room and the mediasoup Router. 128 | */ 129 | close() 130 | { 131 | logger.debug('close()'); 132 | 133 | this._closed = true; 134 | 135 | // Close the protoo Room. 136 | this._protooRoom.close(); 137 | 138 | // Close the mediasoup Routers. 139 | for (const router of this._mediasoupRouters.values()) 140 | { 141 | router.close(); 142 | } 143 | 144 | // Emit 'close' event. 145 | this.emit('close'); 146 | 147 | // Stop network throttling. 148 | if (this._networkThrottled) 149 | { 150 | throttle.stop({}) 151 | .catch(() => {}); 152 | } 153 | } 154 | 155 | logStatus() 156 | { 157 | logger.info( 158 | 'logStatus() [roomId:%s, protoo Peers:%s, mediasoup Transports:%s]', 159 | this._roomId, 160 | this._protooRoom.peers.length, 161 | ); 162 | } 163 | 164 | getCCU() { 165 | if (!this._protooRoom || !this._protooRoom.peers) return 0; 166 | return this._protooRoom.peers.length; 167 | } 168 | 169 | getPeers(){ 170 | if (!this._protooRoom || !this._protooRoom.peers) return []; 171 | return this._protooRoom.peers; 172 | } 173 | 174 | /** 175 | * Called from server.js upon a protoo WebSocket connection request from a 176 | * browser. 177 | * 178 | * @param {String} peerId - The id of the protoo peer to be created. 179 | * @param {Boolean} consume - Whether this peer wants to consume from others. 180 | * @param {protoo.WebSocketTransport} protooWebSocketTransport - The associated 181 | * protoo WebSocket transport. 182 | */ 183 | async handleProtooConnection({ peerId, consume, protooWebSocketTransport }) 184 | { 185 | const existingPeer = this._protooRoom.getPeer(peerId); 186 | 187 | if (existingPeer) 188 | { 189 | logger.warn( 190 | 'handleProtooConnection() | there is already a protoo Peer with same peerId, closing it [peerId:%s]', 191 | peerId); 192 | 193 | existingPeer.close(); 194 | } 195 | 196 | let peer; 197 | 198 | // Create a new protoo Peer with the given peerId. 199 | try 200 | { 201 | peer = this._protooRoom.createPeer(peerId, protooWebSocketTransport); 202 | } 203 | catch (error) 204 | { 205 | logger.error('protooRoom.createPeer() failed:%o', error); 206 | } 207 | 208 | // Use the peer.data object to store mediasoup related objects. 209 | 210 | // Not joined after a custom protoo 'join' request is later received. 211 | peer.data.consume = consume; 212 | peer.data.joined = false; 213 | peer.data.displayName = undefined; 214 | peer.data.device = undefined; 215 | peer.data.rtpCapabilities = undefined; 216 | peer.data.sctpCapabilities = undefined; 217 | 218 | // Have mediasoup related maps ready even before the Peer joins since we 219 | // allow creating Transports before joining. 220 | peer.data.transports = new Map(); 221 | peer.data.producers = new Map(); 222 | peer.data.consumers = new Map(); 223 | peer.data.dataProducers = new Map(); 224 | peer.data.dataConsumers = new Map(); 225 | peer.data.peerIdToConsumerId = new Map(); 226 | peer.data.blockedPeers = new Set(); 227 | 228 | const [routerId, workerPid] = await this._getRouterId(); 229 | peer.data.routerId = routerId; 230 | peer.data.workerPid = workerPid; 231 | 232 | 233 | peer.on('request', (request, accept, reject) => 234 | { 235 | logger.debug( 236 | 'protoo Peer "request" event [method:%s, peerId:%s]', 237 | request.method, peer.id); 238 | 239 | this._handleProtooRequest(peer, request, accept, reject) 240 | .catch((error) => 241 | { 242 | logger.error('request failed:%o', error); 243 | 244 | reject(error); 245 | }); 246 | }); 247 | 248 | peer.on('close', () => 249 | { 250 | if (this._closed) 251 | return; 252 | 253 | logger.debug('protoo Peer "close" event [peerId:%s]', peer.id); 254 | 255 | // If the Peer was joined, notify all Peers. 256 | if (peer.data.joined) 257 | { 258 | for (const otherPeer of this._getJoinedPeers({ excludePeer: peer })) 259 | { 260 | otherPeer.notify('peerClosed', { peerId: peer.id }) 261 | .catch(() => {}); 262 | } 263 | } 264 | 265 | // Iterate and close all mediasoup Transport associated to this Peer, so all 266 | // its Producers and Consumers will also be closed. 267 | for (const transport of peer.data.transports.values()) 268 | { 269 | transport.close(); 270 | } 271 | 272 | // If this is the latest Peer in the room, close the room. 273 | if (this._protooRoom.peers.length === 0) 274 | { 275 | logger.info( 276 | 'last Peer in the room left, closing the room [roomId:%s]', 277 | this._roomId); 278 | 279 | this.close(); 280 | } 281 | }); 282 | } 283 | 284 | _handleAudioLevelObserver() 285 | { 286 | this._audioLevelObserver.on('volumes', (volumes) => 287 | { 288 | const { producer, volume } = volumes[0]; 289 | 290 | // logger.debug( 291 | // 'audioLevelObserver "volumes" event [producerId:%s, volume:%s]', 292 | // producer.id, volume); 293 | 294 | // Notify all Peers. 295 | for (const peer of this._getJoinedPeers()) 296 | { 297 | peer.notify( 298 | 'activeSpeaker', 299 | { 300 | peerId : producer.appData.peerId, 301 | volume : volume 302 | }) 303 | .catch(() => {}); 304 | } 305 | }); 306 | 307 | this._audioLevelObserver.on('silence', () => 308 | { 309 | // logger.debug('audioLevelObserver "silence" event'); 310 | 311 | // Notify all Peers. 312 | for (const peer of this._getJoinedPeers()) 313 | { 314 | peer.notify('activeSpeaker', { peerId: null }) 315 | .catch(() => {}); 316 | } 317 | }); 318 | } 319 | 320 | async _consumeExistingProducers(peer, joinedPeers) { 321 | for (const joinedPeer of joinedPeers) 322 | { 323 | // Create Consumers for existing Producers. 324 | for (const producer of joinedPeer.data.producers.values()) 325 | { 326 | await this._createConsumer( 327 | { 328 | consumerPeer : peer, 329 | producerPeer : joinedPeer, 330 | producer 331 | }); 332 | } 333 | 334 | // Create DataConsumers for existing DataProducers. 335 | for (const dataProducer of joinedPeer.data.dataProducers.values()) 336 | { 337 | await this._createDataConsumer( 338 | { 339 | dataConsumerPeer : peer, 340 | dataProducerPeer : joinedPeer, 341 | dataProducer 342 | }); 343 | } 344 | } 345 | 346 | } 347 | 348 | /** 349 | * Handle protoo requests from browsers. 350 | * 351 | * @async 352 | */ 353 | async _handleProtooRequest(peer, request, accept, reject) 354 | { 355 | const router = this._mediasoupRouters.get(peer.data.routerId); 356 | 357 | switch (request.method) 358 | { 359 | case 'getRouterRtpCapabilities': 360 | { 361 | accept(router.rtpCapabilities); 362 | 363 | break; 364 | } 365 | 366 | case 'join': 367 | { 368 | // Ensure the Peer is not already joined. 369 | if (peer.data.joined) 370 | throw new Error('Peer already joined'); 371 | 372 | const { 373 | displayName, 374 | device, 375 | rtpCapabilities, 376 | sctpCapabilities, 377 | token 378 | } = request.data; 379 | 380 | // Store client data into the protoo Peer data object. 381 | peer.data.joined = true; 382 | peer.data.displayName = displayName; 383 | peer.data.device = device; 384 | peer.data.rtpCapabilities = rtpCapabilities; 385 | peer.data.sctpCapabilities = sctpCapabilities; 386 | peer.data.token = token; 387 | 388 | jwt.verify(peer.data.token, this._authKey, { algorithms: ['RS512'] }, (err, decoded) => { 389 | if (err) { 390 | reject(500, err); 391 | return; 392 | } 393 | 394 | if (!decoded.join_hub) { 395 | reject(401); 396 | return; 397 | } 398 | }); 399 | 400 | // Tell the new Peer about already joined Peers. 401 | // And also create Consumers for existing Producers. 402 | 403 | const joinedPeers = 404 | [ 405 | ...this._getJoinedPeers(), 406 | ]; 407 | 408 | // Reply now the request with the list of joined peers (all but the new one). 409 | const peerInfos = joinedPeers 410 | .filter((joinedPeer) => joinedPeer.id !== peer.id) 411 | .map((joinedPeer) => ({ 412 | id : joinedPeer.id, 413 | displayName : joinedPeer.data.displayName, 414 | device : joinedPeer.data.device, 415 | hasProducers : joinedPeer.data.producers.size > 0 416 | })); 417 | 418 | accept({ peers: peerInfos }); 419 | 420 | // Mark the new Peer as joined. 421 | peer.data.joined = true; 422 | 423 | await this._consumeExistingProducers(peer, joinedPeers); 424 | 425 | // Notify the new Peer to all other Peers. 426 | for (const otherPeer of this._getJoinedPeers({ excludePeer: peer })) 427 | { 428 | otherPeer.notify( 429 | 'newPeer', 430 | { 431 | id : peer.id, 432 | displayName : peer.data.displayName, 433 | device : peer.data.device 434 | }) 435 | .catch(() => {}); 436 | } 437 | 438 | break; 439 | } 440 | 441 | case 'refreshConsumers': 442 | { 443 | // Ensure the Peer is already joined. 444 | if (!peer.data.joined) 445 | throw new Error('Peer not joined'); 446 | 447 | accept(); 448 | 449 | const joinedPeers = 450 | [ 451 | ...this._getJoinedPeers({ excludePeer: peer }), 452 | ]; 453 | 454 | await this._consumeExistingProducers(peer, joinedPeers); 455 | 456 | break; 457 | } 458 | 459 | case 'createWebRtcTransport': 460 | { 461 | // NOTE: Don't require that the Peer is joined here, so the client can 462 | // initiate mediasoup Transports and be ready when he later joins. 463 | 464 | const { 465 | forceTcp, 466 | producing, 467 | consuming, 468 | sctpCapabilities 469 | } = request.data; 470 | 471 | const webRtcTransportOptions = 472 | { 473 | ...config.mediasoup.webRtcTransportOptions, 474 | enableSctp : Boolean(sctpCapabilities), 475 | numSctpStreams : (sctpCapabilities || {}).numStreams, 476 | appData : { producing, consuming } 477 | }; 478 | 479 | if (forceTcp) 480 | { 481 | webRtcTransportOptions.enableUdp = false; 482 | webRtcTransportOptions.enableTcp = true; 483 | } 484 | 485 | const transport = await router.createWebRtcTransport( 486 | webRtcTransportOptions); 487 | 488 | transport.on('sctpstatechange', (sctpState) => 489 | { 490 | logger.debug('WebRtcTransport "sctpstatechange" event [sctpState:%s]', sctpState); 491 | }); 492 | 493 | transport.on('dtlsstatechange', (dtlsState) => 494 | { 495 | if (dtlsState === 'failed' || dtlsState === 'closed') 496 | logger.warn('WebRtcTransport "dtlsstatechange" event [dtlsState:%s]', dtlsState); 497 | }); 498 | 499 | // NOTE: For testing. 500 | // await transport.enableTraceEvent([ 'probation', 'bwe' ]); 501 | await transport.enableTraceEvent([ 'bwe' ]); 502 | 503 | transport.on('trace', (trace) => 504 | { 505 | logger.debug( 506 | 'transport "trace" event [transportId:%s, trace.type:%s, trace:%o]', 507 | transport.id, trace.type, trace); 508 | 509 | if (trace.type === 'bwe' && trace.direction === 'out') 510 | { 511 | peer.notify( 512 | 'downlinkBwe', 513 | { 514 | desiredBitrate : trace.info.desiredBitrate, 515 | effectiveDesiredBitrate : trace.info.effectiveDesiredBitrate, 516 | availableBitrate : trace.info.availableBitrate 517 | }) 518 | .catch(() => {}); 519 | } 520 | }); 521 | 522 | // Store the WebRtcTransport into the protoo Peer data Object. 523 | peer.data.transports.set(transport.id, transport); 524 | 525 | transport.observer.on('close', () => { 526 | peer.data.transports.delete(transport.id); 527 | }); 528 | 529 | accept( 530 | { 531 | id : transport.id, 532 | iceParameters : transport.iceParameters, 533 | iceCandidates : transport.iceCandidates, 534 | dtlsParameters : transport.dtlsParameters, 535 | sctpParameters : transport.sctpParameters 536 | }); 537 | 538 | const { maxIncomingBitrate } = config.mediasoup.webRtcTransportOptions; 539 | 540 | // If set, apply max incoming bitrate limit. 541 | if (maxIncomingBitrate) 542 | { 543 | try { await transport.setMaxIncomingBitrate(maxIncomingBitrate); } 544 | catch (error) {} 545 | } 546 | 547 | break; 548 | } 549 | 550 | case 'closeWebRtcTransport': { 551 | const { transportId } = request.data; 552 | const transport = peer.data.transports.get(transportId); 553 | 554 | if (!transport) 555 | throw new Error(`transport with id "${transportId}" not found`); 556 | 557 | transport.close(); 558 | 559 | accept(); 560 | 561 | break; 562 | } 563 | 564 | case 'connectWebRtcTransport': 565 | { 566 | const { transportId, dtlsParameters } = request.data; 567 | const transport = peer.data.transports.get(transportId); 568 | 569 | if (!transport) 570 | throw new Error(`transport with id "${transportId}" not found`); 571 | 572 | await transport.connect({ dtlsParameters }); 573 | 574 | accept(); 575 | 576 | break; 577 | } 578 | 579 | case 'restartIce': 580 | { 581 | const { transportId } = request.data; 582 | const transport = peer.data.transports.get(transportId); 583 | 584 | if (!transport) 585 | throw new Error(`transport with id "${transportId}" not found`); 586 | 587 | const iceParameters = await transport.restartIce(); 588 | 589 | accept(iceParameters); 590 | 591 | break; 592 | } 593 | 594 | case 'produce': 595 | { 596 | // Ensure the Peer is joined. 597 | if (!peer.data.joined) 598 | throw new Error('Peer not yet joined'); 599 | 600 | const { transportId, kind, rtpParameters } = request.data; 601 | let { appData } = request.data; 602 | const transport = peer.data.transports.get(transportId); 603 | 604 | if (!transport) 605 | throw new Error(`transport with id "${transportId}" not found`); 606 | 607 | // Add peerId into appData to later get the associated Peer during 608 | // the 'loudest' event of the audioLevelObserver. 609 | appData = { ...appData, peerId: peer.id }; 610 | 611 | const producer = await transport.produce( 612 | { 613 | kind, 614 | rtpParameters, 615 | appData 616 | // keyFrameRequestDelay: 5000 617 | }); 618 | 619 | for (const [ routerId, targetRouter ] of this._mediasoupRouters) 620 | { 621 | logger.info("this.routerId: %s, that.routerId: %s", peer.data.routerId, routerId); 622 | if (routerId === peer.data.routerId){ 623 | logger.info("skip self"); 624 | continue; 625 | } 626 | 627 | logger.info("piping to (rouerId) %s for (producerId) %s", targetRouter.id, producer.id); 628 | await router.pipeToRouter({ 629 | producerId : producer.id, 630 | router : targetRouter 631 | }); 632 | } 633 | 634 | // Store the Producer into the protoo Peer data Object. 635 | peer.data.producers.set(producer.id, producer); 636 | 637 | // Set Producer events. 638 | producer.on('score', (score) => 639 | { 640 | // logger.debug( 641 | // 'producer "score" event [producerId:%s, score:%o]', 642 | // producer.id, score); 643 | 644 | peer.notify('producerScore', { producerId: producer.id, score }) 645 | .catch(() => {}); 646 | }); 647 | 648 | producer.on('videoorientationchange', (videoOrientation) => 649 | { 650 | logger.debug( 651 | 'producer "videoorientationchange" event [producerId:%s, videoOrientation:%o]', 652 | producer.id, videoOrientation); 653 | }); 654 | 655 | // NOTE: For testing. 656 | // await producer.enableTraceEvent([ 'rtp', 'keyframe', 'nack', 'pli', 'fir' ]); 657 | // await producer.enableTraceEvent([ 'pli', 'fir' ]); 658 | // await producer.enableTraceEvent([ 'keyframe' ]); 659 | 660 | producer.on('trace', (trace) => 661 | { 662 | logger.debug( 663 | 'producer "trace" event [producerId:%s, trace.type:%s, trace:%o]', 664 | producer.id, trace.type, trace); 665 | }); 666 | 667 | accept({ id: producer.id }); 668 | 669 | // Optimization: Create a server-side Consumer for each Peer. 670 | for (const otherPeer of this._getJoinedPeers({ excludePeer: peer })) 671 | { 672 | this._createConsumer( 673 | { 674 | consumerPeer : otherPeer, 675 | producerPeer : peer, 676 | producer 677 | }); 678 | } 679 | 680 | // Add into the audioLevelObserver. 681 | if (producer.kind === 'audio') 682 | { 683 | this._audioLevelObserver.addProducer({ producerId: producer.id }) 684 | .catch(() => {}); 685 | } 686 | 687 | break; 688 | } 689 | 690 | case 'closeProducer': 691 | { 692 | // Ensure the Peer is joined. 693 | if (!peer.data.joined) 694 | throw new Error('Peer not yet joined'); 695 | 696 | const { producerId } = request.data; 697 | const producer = peer.data.producers.get(producerId); 698 | 699 | if (!producer) 700 | throw new Error(`producer with id "${producerId}" not found`); 701 | 702 | producer.close(); 703 | 704 | // Remove from its map. 705 | peer.data.producers.delete(producer.id); 706 | 707 | accept(); 708 | 709 | break; 710 | } 711 | 712 | case 'pauseProducer': 713 | { 714 | // Ensure the Peer is joined. 715 | if (!peer.data.joined) 716 | throw new Error('Peer not yet joined'); 717 | 718 | const { producerId } = request.data; 719 | const producer = peer.data.producers.get(producerId); 720 | 721 | if (!producer) 722 | throw new Error(`producer with id "${producerId}" not found`); 723 | 724 | await producer.pause(); 725 | 726 | accept(); 727 | 728 | break; 729 | } 730 | 731 | case 'resumeProducer': 732 | { 733 | // Ensure the Peer is joined. 734 | if (!peer.data.joined) 735 | throw new Error('Peer not yet joined'); 736 | 737 | const { producerId } = request.data; 738 | const producer = peer.data.producers.get(producerId); 739 | 740 | if (!producer) 741 | throw new Error(`producer with id "${producerId}" not found`); 742 | 743 | await producer.resume(); 744 | 745 | accept(); 746 | 747 | break; 748 | } 749 | 750 | case 'pauseConsumer': 751 | { 752 | // Ensure the Peer is joined. 753 | if (!peer.data.joined) 754 | throw new Error('Peer not yet joined'); 755 | 756 | const { consumerId } = request.data; 757 | const consumer = peer.data.consumers.get(consumerId); 758 | 759 | if (!consumer) 760 | throw new Error(`consumer with id "${consumerId}" not found`); 761 | 762 | await consumer.pause(); 763 | 764 | accept(); 765 | 766 | break; 767 | } 768 | 769 | case 'resumeConsumer': 770 | { 771 | // Ensure the Peer is joined. 772 | if (!peer.data.joined) 773 | throw new Error('Peer not yet joined'); 774 | 775 | const { consumerId } = request.data; 776 | const consumer = peer.data.consumers.get(consumerId); 777 | 778 | if (!consumer) 779 | throw new Error(`consumer with id "${consumerId}" not found`); 780 | 781 | await consumer.resume(); 782 | 783 | accept(); 784 | 785 | break; 786 | } 787 | 788 | case 'setConsumerPreferredLayers': 789 | { 790 | // Ensure the Peer is joined. 791 | if (!peer.data.joined) 792 | throw new Error('Peer not yet joined'); 793 | 794 | const { consumerId, spatialLayer, temporalLayer } = request.data; 795 | const consumer = peer.data.consumers.get(consumerId); 796 | 797 | if (!consumer) 798 | throw new Error(`consumer with id "${consumerId}" not found`); 799 | 800 | await consumer.setPreferredLayers({ spatialLayer, temporalLayer }); 801 | 802 | accept(); 803 | 804 | break; 805 | } 806 | 807 | case 'setConsumerPriority': 808 | { 809 | // Ensure the Peer is joined. 810 | if (!peer.data.joined) 811 | throw new Error('Peer not yet joined'); 812 | 813 | const { consumerId, priority } = request.data; 814 | const consumer = peer.data.consumers.get(consumerId); 815 | 816 | if (!consumer) 817 | throw new Error(`consumer with id "${consumerId}" not found`); 818 | 819 | await consumer.setPriority(priority); 820 | 821 | accept(); 822 | 823 | break; 824 | } 825 | 826 | case 'requestConsumerKeyFrame': 827 | { 828 | // Ensure the Peer is joined. 829 | if (!peer.data.joined) 830 | throw new Error('Peer not yet joined'); 831 | 832 | const { consumerId } = request.data; 833 | const consumer = peer.data.consumers.get(consumerId); 834 | 835 | if (!consumer) 836 | throw new Error(`consumer with id "${consumerId}" not found`); 837 | 838 | await consumer.requestKeyFrame(); 839 | 840 | accept(); 841 | 842 | break; 843 | } 844 | 845 | case 'produceData': 846 | { 847 | // Ensure the Peer is joined. 848 | if (!peer.data.joined) 849 | throw new Error('Peer not yet joined'); 850 | 851 | const { 852 | transportId, 853 | sctpStreamParameters, 854 | label, 855 | protocol, 856 | appData 857 | } = request.data; 858 | 859 | const transport = peer.data.transports.get(transportId); 860 | 861 | if (!transport) 862 | throw new Error(`transport with id "${transportId}" not found`); 863 | 864 | const dataProducer = await transport.produceData( 865 | { 866 | sctpStreamParameters, 867 | label, 868 | protocol, 869 | appData 870 | }); 871 | 872 | // Store the Producer into the protoo Peer data Object. 873 | peer.data.dataProducers.set(dataProducer.id, dataProducer); 874 | 875 | accept({ id: dataProducer.id }); 876 | 877 | switch (dataProducer.label) 878 | { 879 | case 'chat': 880 | { 881 | // Create a server-side DataConsumer for each Peer. 882 | for (const otherPeer of this._getJoinedPeers({ excludePeer: peer })) 883 | { 884 | this._createDataConsumer( 885 | { 886 | dataConsumerPeer : otherPeer, 887 | dataProducerPeer : peer, 888 | dataProducer 889 | }); 890 | } 891 | break; 892 | } 893 | } 894 | 895 | break; 896 | } 897 | 898 | case 'changeDisplayName': 899 | { 900 | // Ensure the Peer is joined. 901 | if (!peer.data.joined) 902 | throw new Error('Peer not yet joined'); 903 | 904 | const { displayName } = request.data; 905 | const oldDisplayName = peer.data.displayName; 906 | 907 | // Store the display name into the custom data Object of the protoo 908 | // Peer. 909 | peer.data.displayName = displayName; 910 | 911 | // Notify other joined Peers. 912 | for (const otherPeer of this._getJoinedPeers({ excludePeer: peer })) 913 | { 914 | otherPeer.notify( 915 | 'peerDisplayNameChanged', 916 | { 917 | peerId : peer.id, 918 | displayName, 919 | oldDisplayName 920 | }) 921 | .catch(() => {}); 922 | } 923 | 924 | accept(); 925 | 926 | break; 927 | } 928 | 929 | case 'getTransportStats': 930 | { 931 | const { transportId } = request.data; 932 | const transport = peer.data.transports.get(transportId); 933 | 934 | if (!transport) 935 | throw new Error(`transport with id "${transportId}" not found`); 936 | 937 | const stats = await transport.getStats(); 938 | 939 | accept(stats); 940 | 941 | break; 942 | } 943 | 944 | case 'getProducerStats': 945 | { 946 | const { producerId } = request.data; 947 | const producer = peer.data.producers.get(producerId); 948 | 949 | if (!producer) 950 | throw new Error(`producer with id "${producerId}" not found`); 951 | 952 | const stats = await producer.getStats(); 953 | 954 | accept(stats); 955 | 956 | break; 957 | } 958 | 959 | case 'getConsumerStats': 960 | { 961 | const { consumerId } = request.data; 962 | const consumer = peer.data.consumers.get(consumerId); 963 | 964 | if (!consumer) 965 | throw new Error(`consumer with id "${consumerId}" not found`); 966 | 967 | const stats = await consumer.getStats(); 968 | 969 | accept(stats); 970 | 971 | break; 972 | } 973 | 974 | case 'getDataProducerStats': 975 | { 976 | const { dataProducerId } = request.data; 977 | const dataProducer = peer.data.dataProducers.get(dataProducerId); 978 | 979 | if (!dataProducer) 980 | throw new Error(`dataProducer with id "${dataProducerId}" not found`); 981 | 982 | const stats = await dataProducer.getStats(); 983 | 984 | accept(stats); 985 | 986 | break; 987 | } 988 | 989 | case 'getDataConsumerStats': 990 | { 991 | const { dataConsumerId } = request.data; 992 | const dataConsumer = peer.data.dataConsumers.get(dataConsumerId); 993 | 994 | if (!dataConsumer) 995 | throw new Error(`dataConsumer with id "${dataConsumerId}" not found`); 996 | 997 | const stats = await dataConsumer.getStats(); 998 | 999 | accept(stats); 1000 | 1001 | break; 1002 | } 1003 | 1004 | case 'applyNetworkThrottle': 1005 | { 1006 | // reject(501, 'do we need this?') 1007 | // return 1008 | 1009 | const DefaultUplink = 1000000; 1010 | const DefaultDownlink = 1000000; 1011 | const DefaultRtt = 0; 1012 | 1013 | const { uplink, downlink, rtt, secret } = request.data; 1014 | 1015 | if (!secret || secret !== process.env.NETWORK_THROTTLE_SECRET) 1016 | { 1017 | reject(403, 'operation NOT allowed, modda fuckaa'); 1018 | 1019 | return; 1020 | } 1021 | 1022 | try 1023 | { 1024 | await throttle.start( 1025 | { 1026 | up : uplink || DefaultUplink, 1027 | down : downlink || DefaultDownlink, 1028 | rtt : rtt || DefaultRtt 1029 | }); 1030 | 1031 | logger.warn( 1032 | 'network throttle set [uplink:%s, downlink:%s, rtt:%s]', 1033 | uplink || DefaultUplink, 1034 | downlink || DefaultDownlink, 1035 | rtt || DefaultRtt); 1036 | 1037 | accept(); 1038 | } 1039 | catch (error) 1040 | { 1041 | logger.error('network throttle apply failed: %o', error); 1042 | 1043 | reject(500, error.toString()); 1044 | } 1045 | 1046 | break; 1047 | } 1048 | 1049 | case 'resetNetworkThrottle': 1050 | { 1051 | // reject(501, 'do we need this?') 1052 | // return 1053 | 1054 | const { secret } = request.data; 1055 | 1056 | if (!secret || secret !== process.env.NETWORK_THROTTLE_SECRET) 1057 | { 1058 | reject(403, 'operation NOT allowed, modda fuckaa'); 1059 | 1060 | return; 1061 | } 1062 | 1063 | try 1064 | { 1065 | await throttle.stop({}); 1066 | 1067 | logger.warn('network throttle stopped'); 1068 | 1069 | accept(); 1070 | } 1071 | catch (error) 1072 | { 1073 | logger.error('network throttle stop failed: %o', error); 1074 | 1075 | reject(500, error.toString()); 1076 | } 1077 | 1078 | break; 1079 | } 1080 | 1081 | case 'kick': 1082 | { 1083 | jwt.verify(peer.data.token, this._authKey, { algorithms: ['RS512'] }, (err, decoded) => { 1084 | if (err) { 1085 | reject(500, err); 1086 | } else { 1087 | if (decoded.kick_users) { 1088 | const consumerId = request.data.user_id; 1089 | if (this._protooRoom.hasPeer(consumerId)) { 1090 | this._protooRoom.getPeer(consumerId).close(); 1091 | } 1092 | accept(); 1093 | } else { 1094 | reject(401); 1095 | } 1096 | } 1097 | }); 1098 | break; 1099 | } 1100 | 1101 | case 'block': 1102 | { 1103 | const localPeer = peer; 1104 | const remotePeerId = request.data.whom; 1105 | 1106 | const localConsumerId = localPeer.data.peerIdToConsumerId.get(remotePeerId); 1107 | const localConsumer = localPeer.data.consumers.get(localConsumerId); 1108 | if (localConsumer) { 1109 | localConsumer.pause(); 1110 | } 1111 | 1112 | localPeer.data.blockedPeers.add(remotePeerId); 1113 | 1114 | if (this._protooRoom.hasPeer(remotePeerId)) { 1115 | const remotePeer = this._protooRoom.getPeer(remotePeerId) 1116 | const remoteConsumerId = remotePeer.data.peerIdToConsumerId.get(localPeer.id); 1117 | const remoteConsumer = remotePeer.data.consumers.get(remoteConsumerId); 1118 | if(remoteConsumer) { 1119 | remoteConsumer.pause(); 1120 | } 1121 | remotePeer.notify('peerBlocked', { peerId: localPeer.id }).catch(() => {}); 1122 | } 1123 | accept(); 1124 | break; 1125 | } 1126 | 1127 | case 'unblock': 1128 | { 1129 | const localPeer = peer; 1130 | const remotePeerId = request.data.whom; 1131 | 1132 | localPeer.data.blockedPeers.delete(remotePeerId); 1133 | 1134 | if (this._protooRoom.hasPeer(remotePeerId)) { 1135 | const remotePeer = this._protooRoom.getPeer(remotePeerId); 1136 | if (!remotePeer.data.blockedPeers.has(localPeer.id)) { 1137 | const localConsumer = localPeer.data.consumers.get(localPeer.data.peerIdToConsumerId.get(remotePeerId)); 1138 | if (localConsumer) { 1139 | localConsumer.resume(); 1140 | } 1141 | const remoteConsumer = remotePeer.data.consumers.get(remotePeer.data.peerIdToConsumerId.get(localPeer.id)); 1142 | if (remoteConsumer) { 1143 | remoteConsumer.resume(); 1144 | } 1145 | localPeer.notify('peerUnblocked', { peerId: remotePeer.id }).catch(() => {}); 1146 | remotePeer.notify('peerUnblocked', { peerId: localPeer.id }).catch(() => {}); 1147 | } 1148 | } 1149 | accept(); 1150 | break; 1151 | } 1152 | 1153 | default: 1154 | { 1155 | logger.error('unknown request.method "%s"', request.method); 1156 | 1157 | reject(500, `unknown request.method "${request.method}"`); 1158 | } 1159 | } 1160 | } 1161 | 1162 | /** 1163 | * Helper to get the list of joined protoo peers. 1164 | */ 1165 | _getJoinedPeers({ excludePeer = undefined } = {}) 1166 | { 1167 | return this._protooRoom.peers 1168 | .filter((peer) => peer.data.joined && peer !== excludePeer); 1169 | } 1170 | 1171 | /** 1172 | * Creates a mediasoup Consumer for the given mediasoup Producer. 1173 | * 1174 | * @async 1175 | */ 1176 | async _createConsumer({ consumerPeer, producerPeer, producer }) 1177 | { 1178 | // Optimization: 1179 | // - Create the server-side Consumer in paused mode. 1180 | // - Tell its Peer about it and wait for its response. 1181 | // - Upon receipt of the response, resume the server-side Consumer. 1182 | // - If video, this will mean a single key frame requested by the 1183 | // server-side Consumer (when resuming it). 1184 | // - If audio (or video), it will avoid that RTP packets are received by the 1185 | // remote endpoint *before* the Consumer is locally created in the endpoint 1186 | // (and before the local SDP O/A procedure ends). If that happens (RTP 1187 | // packets are received before the SDP O/A is done) the PeerConnection may 1188 | // fail to associate the RTP stream. 1189 | 1190 | // NOTE: Don't create the Consumer if the remote Peer cannot consume it. 1191 | if ( 1192 | !consumerPeer.data.rtpCapabilities || 1193 | !this._mediasoupRouters.get(consumerPeer.data.routerId).canConsume( 1194 | { 1195 | producerId : producer.id, 1196 | rtpCapabilities : consumerPeer.data.rtpCapabilities 1197 | }) 1198 | ) 1199 | { 1200 | return; 1201 | } 1202 | 1203 | // Must take the Transport the remote Peer is using for consuming. 1204 | const transport = Array.from(consumerPeer.data.transports.values()) 1205 | .find((t) => t.appData.consuming); 1206 | 1207 | // This should not happen. 1208 | if (!transport) 1209 | { 1210 | logger.warn('_createConsumer() | Transport for consuming not found'); 1211 | 1212 | return; 1213 | } 1214 | 1215 | // Create the Consumer in paused mode. 1216 | let consumer; 1217 | 1218 | try 1219 | { 1220 | consumer = await transport.consume( 1221 | { 1222 | producerId : producer.id, 1223 | rtpCapabilities : consumerPeer.data.rtpCapabilities, 1224 | paused : true 1225 | }); 1226 | } 1227 | catch (error) 1228 | { 1229 | logger.warn('_createConsumer() | transport.consume():%o', error); 1230 | 1231 | return; 1232 | } 1233 | 1234 | // Store the Consumer into the protoo consumerPeer data Object. 1235 | consumerPeer.data.consumers.set(consumer.id, consumer); 1236 | consumerPeer.data.peerIdToConsumerId.set(producerPeer.id, consumer.id); 1237 | 1238 | // Set Consumer events. 1239 | consumer.on('transportclose', () => 1240 | { 1241 | // Remove from its map. 1242 | consumerPeer.data.consumers.delete(consumer.id); 1243 | consumerPeer.data.peerIdToConsumerId.delete(producerPeer.id); 1244 | }); 1245 | 1246 | consumer.on('producerclose', () => 1247 | { 1248 | // Remove from its map. 1249 | consumerPeer.data.consumers.delete(consumer.id); 1250 | consumerPeer.data.peerIdToConsumerId.delete(producerPeer.id); 1251 | 1252 | consumerPeer.notify('consumerClosed', { consumerId: consumer.id }) 1253 | .catch(() => {}); 1254 | }); 1255 | 1256 | consumer.on('producerpause', () => 1257 | { 1258 | consumerPeer.notify('consumerPaused', { consumerId: consumer.id }) 1259 | .catch(() => {}); 1260 | }); 1261 | 1262 | consumer.on('producerresume', () => 1263 | { 1264 | consumerPeer.notify('consumerResumed', { consumerId: consumer.id }) 1265 | .catch(() => {}); 1266 | }); 1267 | 1268 | consumer.on('score', (score) => 1269 | { 1270 | // logger.debug( 1271 | // 'consumer "score" event [consumerId:%s, score:%o]', 1272 | // consumer.id, score); 1273 | 1274 | consumerPeer.notify('consumerScore', { consumerId: consumer.id, score }) 1275 | .catch(() => {}); 1276 | }); 1277 | 1278 | consumer.on('layerschange', (layers) => 1279 | { 1280 | consumerPeer.notify( 1281 | 'consumerLayersChanged', 1282 | { 1283 | consumerId : consumer.id, 1284 | spatialLayer : layers ? layers.spatialLayer : null, 1285 | temporalLayer : layers ? layers.temporalLayer : null 1286 | }) 1287 | .catch(() => {}); 1288 | }); 1289 | 1290 | // NOTE: For testing. 1291 | // await consumer.enableTraceEvent([ 'rtp', 'keyframe', 'nack', 'pli', 'fir' ]); 1292 | // await consumer.enableTraceEvent([ 'pli', 'fir' ]); 1293 | // await consumer.enableTraceEvent([ 'keyframe' ]); 1294 | 1295 | consumer.on('trace', (trace) => 1296 | { 1297 | logger.debug( 1298 | 'consumer "trace" event [producerId:%s, trace.type:%s, trace:%o]', 1299 | consumer.id, trace.type, trace); 1300 | }); 1301 | 1302 | // Send a protoo request to the remote Peer with Consumer parameters. 1303 | try 1304 | { 1305 | await consumerPeer.request( 1306 | 'newConsumer', 1307 | { 1308 | peerId : producerPeer.id, 1309 | producerId : producer.id, 1310 | id : consumer.id, 1311 | kind : consumer.kind, 1312 | rtpParameters : consumer.rtpParameters, 1313 | type : consumer.type, 1314 | appData : producer.appData, 1315 | producerPaused : consumer.producerPaused 1316 | }); 1317 | 1318 | // Now that we got the positive response from the remote endpoint, resume 1319 | // the Consumer so the remote endpoint will receive the a first RTP packet 1320 | // of this new stream once its PeerConnection is already ready to process 1321 | // and associate it. 1322 | await consumer.resume(); 1323 | 1324 | consumerPeer.notify( 1325 | 'consumerScore', 1326 | { 1327 | consumerId : consumer.id, 1328 | score : consumer.score 1329 | }) 1330 | .catch(() => {}); 1331 | } 1332 | catch (error) 1333 | { 1334 | logger.warn('_createConsumer() | failed:%o', error); 1335 | } 1336 | } 1337 | 1338 | /** 1339 | * Creates a mediasoup DataConsumer for the given mediasoup DataProducer. 1340 | * 1341 | * @async 1342 | */ 1343 | async _createDataConsumer( 1344 | { 1345 | dataConsumerPeer, 1346 | dataProducerPeer = null, 1347 | dataProducer 1348 | }) 1349 | { 1350 | // NOTE: Don't create the DataConsumer if the remote Peer cannot consume it. 1351 | if (!dataConsumerPeer.data.sctpCapabilities) 1352 | return; 1353 | 1354 | // Must take the Transport the remote Peer is using for consuming. 1355 | const transport = Array.from(dataConsumerPeer.data.transports.values()) 1356 | .find((t) => t.appData.consuming); 1357 | 1358 | // This should not happen. 1359 | if (!transport) 1360 | { 1361 | logger.warn('_createDataConsumer() | Transport for consuming not found'); 1362 | 1363 | return; 1364 | } 1365 | 1366 | // Create the DataConsumer. 1367 | let dataConsumer; 1368 | 1369 | try 1370 | { 1371 | dataConsumer = await transport.consumeData( 1372 | { 1373 | dataProducerId : dataProducer.id 1374 | }); 1375 | } 1376 | catch (error) 1377 | { 1378 | logger.warn('_createDataConsumer() | transport.consumeData():%o', error); 1379 | 1380 | return; 1381 | } 1382 | 1383 | // Store the DataConsumer into the protoo dataConsumerPeer data Object. 1384 | dataConsumerPeer.data.dataConsumers.set(dataConsumer.id, dataConsumer); 1385 | 1386 | // Set DataConsumer events. 1387 | dataConsumer.on('transportclose', () => 1388 | { 1389 | // Remove from its map. 1390 | dataConsumerPeer.data.dataConsumers.delete(dataConsumer.id); 1391 | }); 1392 | 1393 | dataConsumer.on('dataproducerclose', () => 1394 | { 1395 | // Remove from its map. 1396 | dataConsumerPeer.data.dataConsumers.delete(dataConsumer.id); 1397 | 1398 | dataConsumerPeer.notify( 1399 | 'dataConsumerClosed', { dataConsumerId: dataConsumer.id }) 1400 | .catch(() => {}); 1401 | }); 1402 | 1403 | // Send a protoo request to the remote Peer with Consumer parameters. 1404 | try 1405 | { 1406 | await dataConsumerPeer.request( 1407 | 'newDataConsumer', 1408 | { 1409 | peerId : dataProducerPeer ? dataProducerPeer.id : null, 1410 | dataProducerId : dataProducer.id, 1411 | id : dataConsumer.id, 1412 | sctpStreamParameters : dataConsumer.sctpStreamParameters, 1413 | label : dataConsumer.label, 1414 | protocol : dataConsumer.protocol, 1415 | appData : dataProducer.appData 1416 | }); 1417 | } 1418 | catch (error) 1419 | { 1420 | logger.warn('_createDataConsumer() | failed:%o', error); 1421 | } 1422 | } 1423 | 1424 | /** 1425 | * check currently in use mediasoup workers, returns the routerId of the least loaded on 1426 | * @returns (string) routerId 1427 | */ 1428 | async _getRouterId() 1429 | { 1430 | let worker = {}; 1431 | let routerId = ""; 1432 | 1433 | const workers = Array.from(this._inUseMediasoupWorkers.keys()); 1434 | const [leastUsedWorkerIdx, peerCnt] = utils.workerLoadMan.getLeastLoadedWorkerIdx(workers, this._roomId, this._roomReq); 1435 | 1436 | logger.info("_inUseMediasoupWorkers -> leastUsedWorkerIdx: %s, peerCnt: %s", leastUsedWorkerIdx, peerCnt); 1437 | 1438 | if (peerCnt < utils.ccuThreshold) 1439 | { 1440 | worker = workers[leastUsedWorkerIdx]; 1441 | routerId = this._inUseMediasoupWorkers.get(worker); 1442 | return [routerId, worker._pid]; 1443 | } 1444 | // in use workers are all maxed out 1445 | else if (this._arr_unusedMediasoupWorkers.length > 0) 1446 | { 1447 | // looking for available worker among unused workers 1448 | const [leastUsedWorkerIdx_unused, peerCnt_unused] = utils.workerLoadMan.getLeastLoadedWorkerIdx( 1449 | this._arr_unusedMediasoupWorkers, this._roomId, this._roomReq); 1450 | 1451 | logger.info("leastUsedWorkerIdx_unused: %s, peerCnt: %s", leastUsedWorkerIdx_unused, peerCnt_unused); 1452 | 1453 | if (peerCnt_unused < utils.ccuThreshold) 1454 | { 1455 | worker = this._arr_unusedMediasoupWorkers[leastUsedWorkerIdx_unused]; 1456 | 1457 | const { mediaCodecs } = config.mediasoup.routerOptions; 1458 | const newRouter = await worker.createRouter({ mediaCodecs }); 1459 | this._mediasoupRouters.set(newRouter.id, newRouter); 1460 | this._inUseMediasoupWorkers.set(worker, newRouter.id); 1461 | this._arr_unusedMediasoupWorkers.splice(this._arr_unusedMediasoupWorkers.indexOf(worker), 1); 1462 | 1463 | logger.info("new router (id: %s) created on worker (pid: %s) for room (id: %s)", newRouter.id, worker._pid, this._roomId); 1464 | logger.info("this._mediasoupRouters.size: ", this._mediasoupRouters.size); 1465 | 1466 | this._pipeProducersToRouter(newRouter.id); 1467 | 1468 | return [newRouter.id, worker._pid]; 1469 | } 1470 | } 1471 | 1472 | // BAD: everything's maxed out on this server 1473 | // TODO: check other dialogs servers' workers and pipeToRouter_Lan() 1474 | logger.warn("high server load -- all workers maxed out"); 1475 | worker = workers[leastUsedWorkerIdx]; 1476 | routerId = this._inUseMediasoupWorkers.get(worker); 1477 | return [routerId, worker._pid]; 1478 | } 1479 | 1480 | async _pipeProducersToRouter(routerId) 1481 | { 1482 | const router = this._mediasoupRouters.get(routerId); 1483 | 1484 | const peersToPipe = 1485 | Object.values(this._protooRoom.peers) 1486 | .filter((peer) => peer.data.routerId !== routerId && peer.data.routerId !== null); 1487 | 1488 | for (const peer of peersToPipe) 1489 | { 1490 | const srcRouter = this._mediasoupRouters.get(peer.data.routerId); 1491 | 1492 | for (const producerId of peer.data.producers.keys()) 1493 | { 1494 | if (router._producers.has(producerId)) 1495 | { 1496 | logger.info("~~~skipping~parenting~router~~~") 1497 | continue; 1498 | } 1499 | 1500 | await srcRouter.pipeToRouter({ 1501 | producerId : producerId, 1502 | router : router 1503 | }); 1504 | } 1505 | } 1506 | } 1507 | 1508 | } 1509 | 1510 | module.exports = Room; 1511 | --------------------------------------------------------------------------------