From d76fa625d0aa85a7981b3825d06ec1e1e39d4373 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 4 Nov 2022 08:38:18 -0400 Subject: [PATCH] basic mediasoup integration --- app/models/media-router.js | 53 +++++++++++ app/models/media-worker.js | 43 +++++++++ client/js/index.js | 4 +- dtp-media-engine.js | 178 +++++++++++++++++++++++++++++++++++++ lib/site-platform.js | 7 +- package.json | 1 + yarn.lock | 33 +++++++ 7 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 app/models/media-router.js create mode 100644 app/models/media-worker.js create mode 100644 dtp-media-engine.js diff --git a/app/models/media-router.js b/app/models/media-router.js new file mode 100644 index 0000000..c8dc32c --- /dev/null +++ b/app/models/media-router.js @@ -0,0 +1,53 @@ +// media-router.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const STATUS_LIST = [ + 'starting', // the router process is starting and configuring itself + 'active', // the router is active and available for service + 'capacity', // the router is at or over capacity + 'closing', // the router is closing/shutting down + 'closed', // the router no longer exists +]; + +const RouterHostSchema = new Schema({ + address: { type: String, required: true, index: 1 }, + port: { type: Number, required: true }, +}); + +/* + * A Router is a "multi-user conference call instance" somewhere on the + * infrastructure. This model helps us manage them, balance load across them, + * and route calls to and between them (for scale). + * + * These records are created when a call is being created, and are commonly + * left in the database after all call participants have left. An expires index + * is used to sweep up router records after 30 days. This allows us to perform + * statistics aggregation on router use and store aggregated results as part of + * long-term reporting. + */ +const MediaRouterSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, expires: '30d' }, + lastActivity: { type: Date, default: Date.now, required: true }, + status: { type: String, enum: STATUS_LIST, default: 'starting', required: true, index: true }, + name: { type: String }, + description: { type: String }, + access: { + isPrivate: { type: Boolean, default: true, required: true }, + passcodeHash: { type: String, select: false }, + }, + host: { type: RouterHostSchema, required: true, select: false }, + stats: { + routerCount: { type: Number, default: 0, required: true }, + consumerCount: { type: Number, default: 0, required: true }, + producerCount: { type: Number, default: 0, required: true }, + } +}); + +module.exports = mongoose.model('MediaRouter', MediaRouterSchema); \ No newline at end of file diff --git a/app/models/media-worker.js b/app/models/media-worker.js new file mode 100644 index 0000000..feebc87 --- /dev/null +++ b/app/models/media-worker.js @@ -0,0 +1,43 @@ +// media-worker.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const STATUS_LIST = [ + 'starting', // the router process is starting and configuring itself + 'active', // the router is active and available for service + 'capacity', // the router is at or over capacity + 'closing', // the router is closing/shutting down + 'closed', // the router no longer exists +]; + +const WebRtcListenSchema = new Schema({ + protocol: { type: String, enum: ['tcp','udp'], required: true }, + ip: { type: String, required: true }, + port: { type: Number, required: true }, +}); + +/* + * A media worker is a host process with one or more MediaRouter instances + * processing multi-user conference calls. + */ +const MediaWorkerSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, expires: '30d' }, + lastActivity: { type: Date, default: Date.now, required: true }, + status: { type: String, enum: STATUS_LIST, default: 'starting', required: true, index: true }, + webRtcServer: { + listenInfos: { type: [WebRtcListenSchema] }, + }, + stats: { + routerCount: { type: Number, default: 0, required: true }, + consumerCount: { type: Number, default: 0, required: true }, + producerCount: { type: Number, default: 0, required: true }, + } +}); + +module.exports = mongoose.model('MediaWorker', MediaWorkerSchema); \ No newline at end of file diff --git a/client/js/index.js b/client/js/index.js index cb321bc..3288abe 100644 --- a/client/js/index.js +++ b/client/js/index.js @@ -28,7 +28,9 @@ window.addEventListener('load', async ( ) => { // service worker if ('serviceWorker' in navigator) { try { - dtp.registration = await navigator.serviceWorker.register('/dist/js/service_worker.min.js'); + dtp.registration = await navigator.serviceWorker.register('/dist/js/service_worker.min.js', { + scope: '/', + }); dtp.log.info('load', 'service worker startup complete', { scope: dtp.registration.scope }); } catch (error) { console.log('service worker startup failed', { error }); diff --git a/dtp-media-engine.js b/dtp-media-engine.js new file mode 100644 index 0000000..8e891ba --- /dev/null +++ b/dtp-media-engine.js @@ -0,0 +1,178 @@ +// dtp-media-engine.js +// Copyright (C) 2022 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +require('dotenv').config(); + +const path = require('path'); + +const mongoose = require('mongoose'); +const mediasoup = require('mediasoup'); + +const { SiteAsync, SiteCommon, SitePlatform, SiteLog } = require(path.join(__dirname, 'lib', 'site-lib')); + +module.rootPath = __dirname; +module.pkg = require(path.join(module.rootPath, 'package.json')); +module.config = { + component: { name: 'dtpMediaEngine', slug: 'dtp-media-engine' }, + root: module.rootPath, + site: require(path.join(module.rootPath, 'config', 'site')), + webRtcServer: [ + { + protocol: 'udp', + ip: process.env.MEDIASOUP_WEBRTC_BIND_ADDR || '127.0.0.1', + port: process.env.MEDIASOUP_WEBRTC_BIND_PORT || 20000, + } + ] +}; + +module.log = new SiteLog(module, module.config.component); + +class MediaEngineWorker extends SiteCommon { + + constructor ( ) { + super(module, { name: 'dtpMediaWorker', slug: 'dtp-media-worker' }); + this._id = mongoose.Types.ObjectId(); + } + + async start ( ) { + await super.start(); + + try { + this.worker = await mediasoup.createWorker({ + logLevel: 'warn', + dtlsCertificateFile: process.env.HTTPS_SSL_CRT, + dtlsPrivateKeyFile: process.env.HTTPS_SSL_KEY, + }); + } catch (error) { + throw new Error(`failed to start mediasoup worker process: ${error.message}`); + } + + try { + const BIND_PORT = 20000 + module.nextWorkerIdx++; + this.webRtcServer = await this.worker.createWebRtcServer({ + listenInfos: [ + { + protocol: 'udp', + ip: '127.0.0.1', + port: BIND_PORT, + }, + { + protocol: 'tcp', + ip: '127.0.0.1', + port: BIND_PORT, + }, + ], + }); + } catch (error) { + throw new Error(`failed to start mediasoup WebRTC Server: ${error.message}`); + } + } + + async stop ( ) { + if (this.webRtcServer && !this.webRtcServer.closed) { + this.log.info('closing mediasoup WebRTC server'); + this.webRtcServer.close(); + delete this.webRtcServer; + } + + if (this.worker && !this.worker.closed) { + this.log.info('closing mediasoup worker process'); + this.worker.close(); + delete this.worker; + } + + await super.stop(); + } +} + +module.onNewWorker = async (worker) => { + module.log.info('new worker created', { worker: worker.pid }); + worker.observer.on('close', ( ) => { + module.log.info('worker shutting down', { worker: worker.pid }); + }); + + worker.observer.on('newrouter', (router) => { + module.log.info('new router created', { worker: worker.pid, router: router.id }); + router.observer.on('close', ( ) => { + module.log.info('router shutting down', { worker: worker.pid, router: router.id }); + }); + }); +}; + +module.createWorker = async ( ) => { + const worker = new MediaEngineWorker(); + module.workers.push(worker); + await worker.start(); +}; + +module.shutdown = async ( ) => { + await SiteAsync.each(module.workers, async (worker) => { + try { + await worker.stop(); + } catch (error) { + module.log.error('failed to stop worker', { error }); + } + }); +}; + +/* + * SERVER PROCESS INIT + */ + +(async ( ) => { + + process.on('unhandledRejection', (error, p) => { + module.log.error('Unhandled rejection', { + error: error, + promise: p, + stack: error.stack + }); + }); + + process.on('warning', (error) => { + module.log.alert('warning', { error }); + }); + + process.once('SIGINT', async ( ) => { + module.log.info('SIGINT received'); + module.log.info('requesting shutdown...'); + await module.shutdown(); + const exitCode = await SitePlatform.shutdown(); + process.nextTick(( ) => { + process.exit(exitCode); + }); + }); + + process.once('SIGUSR2', async ( ) => { + await SitePlatform.shutdown(); + process.kill(process.pid, 'SIGUSR2'); + }); + + try { + await SitePlatform.startPlatform(module); + } catch (error) { + module.log.error(`failed to start DTP ${module.config.component.slug} process`, { error }); + return; + } + + try { + module.log.info('registering mediasoup observer callbacks'); + mediasoup.observer.on('newworker', module.onNewWorker); + + module.log.info('creating mediasoup worker instance'); + + module.nextWorkerIdx = 0; + module.workers = [ ]; + + await module.createWorker(); + + module.log.info('DTP Media Engine online'); + } catch (error) { + module.log.error('failed to start DTP Media Engine', { error }); + process.exit(-1); + } + +})(); \ No newline at end of file diff --git a/lib/site-platform.js b/lib/site-platform.js index 5d628a7..2b108f7 100644 --- a/lib/site-platform.js +++ b/lib/site-platform.js @@ -250,11 +250,16 @@ module.exports.startWebServer = async (dtp) => { return next(); } + function serviceWorkerAllowed (req, res, next) { + res.set('Service-Worker-Allowed', '/'); + return next(); + } + /* * Static file services (project) */ module.app.use(express.static(path.join(dtp.config.root, 'client'))); - module.app.use('/dist', cacheOneDay, express.static(path.join(dtp.config.root, 'dist'))); + module.app.use('/dist', cacheOneDay, serviceWorkerAllowed, express.static(path.join(dtp.config.root, 'dist'))); module.app.use('/img', cacheOneDay, express.static(path.join(dtp.config.root, 'client', 'img'))); /* diff --git a/package.json b/package.json index a5c2c4a..05b1eaf 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "jsdom": "^19.0.0", "libphonenumber-js": "^1.9.49", "marked": "^4.0.12", + "mediasoup": "3", "method-override": "^3.0.0", "mime": "^3.0.0", "minio": "^7.0.26", diff --git a/yarn.lock b/yarn.lock index 45a6a2b..d3318d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1063,6 +1063,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.11.tgz#6ea7342dfb379ea1210835bada87b3c512120234" integrity sha512-KB0sixD67CeecHC33MYn+eYARkqTheIRNuu97y2XMjR7Wu3XibO1vaY6VBV6O/a89SPI81cEUIYT87UqUWlZNw== +"@types/node@^16.11.10": + version "16.18.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.3.tgz#d7f7ba828ad9e540270f01ce00d391c54e6e0abc" + integrity sha512-jh6m0QUhIRcZpNv7Z/rpN+ZWXOicUUQbSoWks7Htkbb9IjFQj4kzcX/xFCkjstCj5flMsN8FiSvt+q+Tcs4Llg== + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" @@ -4262,6 +4267,13 @@ gulplog@^1.0.0: dependencies: glogg "^1.0.0" +h264-profile-level-id@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/h264-profile-level-id/-/h264-profile-level-id-1.0.1.tgz#92033c190766c846e57c6a97e4c1d922943a9cce" + integrity sha512-D3Rln/jKNjKDW5ZTJTK3niSoOGE+pFqPvRHHVgQN3G7umcn/zWGPUo8Q8VpDj16x3hKz++zVviRNRmXu5cpN+Q== + dependencies: + debug "^4.1.1" + has-ansi@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" @@ -5591,6 +5603,17 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +mediasoup@3: + version "3.10.12" + resolved "https://registry.yarnpkg.com/mediasoup/-/mediasoup-3.10.12.tgz#509c8c8ebe950dbb056ed8dbd077b3a5e902a229" + integrity sha512-cb+Jn51QQOUrZONT1vUzoIIY0wVGsYVNm8ghOGLcmpM90IVceAhBtqXsT/zUgSSMIS/ZUkUOo8YnyTBZMeqkJg== + dependencies: + "@types/node" "^16.11.10" + debug "^4.3.4" + h264-profile-level-id "^1.0.1" + supports-color "^9.2.3" + uuid "^9.0.0" + memory-fs@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.5.0.tgz#324c01288b88652966d161db77838720845a8e3c" @@ -8127,6 +8150,11 @@ supports-color@^8.0.0, supports-color@^8.1.1: dependencies: has-flag "^4.0.0" +supports-color@^9.2.3: + version "9.2.3" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-9.2.3.tgz#a6e2c97fc20c80abecd69e50aebe4783ff77d45a" + integrity sha512-aszYUX/DVK/ed5rFLb/dDinVJrQjG/vmU433wtqVSD800rYsJNWxh2R3USV90aLSU+UsyQkbNeffVLzc6B6foA== + sver-compat@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/sver-compat/-/sver-compat-1.5.0.tgz#3cf87dfeb4d07b4a3f14827bc186b3fd0c645cd8" @@ -8711,6 +8739,11 @@ uuid@^8.3.0, uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +uuid@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" + integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== + v8flags@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-3.2.0.tgz#b243e3b4dfd731fa774e7492128109a0fe66d656"