├── rules ├── .gitkeep └── validJsdocCustomRule.js ├── src ├── common │ ├── pipes │ │ └── .gitkeep │ ├── utils │ │ └── .gitkeep │ ├── helpers │ │ ├── array.helper.ts │ │ ├── url.helper.ts │ │ └── req.helper.ts │ ├── middlewares │ │ ├── cors.middleware.ts │ │ └── logger.moddleware.ts │ └── errors │ │ └── index.ts ├── logger │ ├── logger.module.ts │ └── logger.service.ts ├── healthcheck │ ├── healthcheck.module.ts │ └── healthcheck.controller.ts ├── wss │ ├── wss.module.ts │ ├── wss.controller.ts │ ├── wss.interfaces.ts │ ├── wss.gateway.ts │ └── wss.room.ts ├── app.module.ts └── main.ts ├── nest-cli.json ├── .gitignore ├── .prettierrc ├── tsconfig.build.json ├── nodemon.json ├── nodemon-debug.json ├── tsconfig.json ├── README.md ├── types └── global.d.ts ├── config └── default.json ├── package.json └── tslint.json /rules/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/common/pipes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/common/utils/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | src/graphql/schema.ts 4 | src/graphql/schema.graphql 5 | 6 | dist 7 | 8 | .idea 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "typescript", 3 | "printWidth": 120, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "test", 6 | "**/*spec.ts", 7 | "**/*/schema.ts" 8 | ] 9 | } -------------------------------------------------------------------------------- /src/common/helpers/array.helper.ts: -------------------------------------------------------------------------------- 1 | export function pluck(array: T[], key: string): K[] { 2 | // tslint:disable-next-line: no-unsafe-any 3 | return array.map(a => a[key]); 4 | } 5 | -------------------------------------------------------------------------------- /src/logger/logger.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { LoggerService } from './logger.service'; 3 | 4 | @Module({ 5 | providers: [LoggerService], 6 | exports: [LoggerService], 7 | }) 8 | export class LoggerModule {} 9 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src" 4 | ], 5 | "env": { 6 | "NODE_ENV": "development" 7 | }, 8 | "ext": "ts", 9 | "ignore": [ 10 | "src/**/*.spec.ts" 11 | ], 12 | "exec": "ts-node --files -r tsconfig-paths/register src/main.ts" 13 | } -------------------------------------------------------------------------------- /nodemon-debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src" 4 | ], 5 | "ext": "ts", 6 | "env": { 7 | "NODE_ENV": "development" 8 | }, 9 | "ignore": [ 10 | "src/**/*.spec.ts" 11 | ], 12 | "exec": "node --inspect-brk -r ts-node/register -r tsconfig-paths/register src/main.ts" 13 | } -------------------------------------------------------------------------------- /src/healthcheck/healthcheck.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { HealthcheckController } from './healthcheck.controller'; 4 | 5 | @Module({ 6 | imports: [], 7 | providers: [], 8 | controllers: [HealthcheckController], 9 | exports: [], 10 | }) 11 | export class HealthcheckModule {} 12 | -------------------------------------------------------------------------------- /src/healthcheck/healthcheck.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { ApiResponse, ApiUseTags } from '@nestjs/swagger'; 3 | 4 | @ApiUseTags('healthz') 5 | @Controller('healthz') 6 | export class HealthcheckController { 7 | @ApiResponse({ 8 | status: 200, 9 | description: 'Health check', 10 | }) 11 | @Get() 12 | public async healthz() { 13 | return { message: 'OK' }; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/common/helpers/url.helper.ts: -------------------------------------------------------------------------------- 1 | import config from 'config'; 2 | import { stringify } from 'query-string'; 3 | 4 | const appSettings = config.get('APP_SETTINGS'); 5 | 6 | /** 7 | * Подставляет clint и query в юрл. 8 | * @param {string} url юрл-ресурса 9 | * @param {object} query query-параметры 10 | * @returns {string} url. 11 | */ 12 | export const createUrlWithQuery = (url: string, query: object = {}): string => { 13 | return `${url}?${stringify({ ...appSettings.client })}&${stringify({ ...query })}`; 14 | }; 15 | -------------------------------------------------------------------------------- /src/wss/wss.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { LoggerService } from '../logger/logger.service'; 4 | 5 | import { WssController } from './wss.controller'; 6 | import { WssGateway } from './wss.gateway'; 7 | 8 | @Module({ 9 | imports: [], 10 | providers: [ 11 | WssGateway, 12 | { 13 | provide: LoggerService, 14 | useValue: new LoggerService('Websocket'), 15 | }, 16 | ], 17 | exports: [WssGateway], 18 | controllers: [WssController], 19 | }) 20 | export class WssModule {} 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "skipLibCheck": true, 6 | "declaration": true, 7 | "removeComments": true, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": true, 14 | "target": "esnext", 15 | "sourceMap": true, 16 | "outDir": "./dist", 17 | "baseUrl": ".", 18 | "typeRoots": [ 19 | "node_modules/@types", 20 | "types" 21 | ] 22 | }, 23 | "exclude": [ 24 | "node_modules", 25 | "dist" 26 | ] 27 | } -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 2 | 3 | import { CorsMiddleware } from './common/middlewares/cors.middleware'; 4 | import { LoggerMiddleware } from './common/middlewares/logger.moddleware'; 5 | 6 | import { LoggerModule } from './logger/logger.module'; 7 | 8 | import { HealthcheckModule } from './healthcheck/healthcheck.module'; 9 | 10 | import { WssModule } from './wss/wss.module'; 11 | 12 | @Module({ 13 | imports: [LoggerModule, HealthcheckModule, WssModule], 14 | providers: [], 15 | controllers: [], 16 | }) 17 | export class AppModule implements NestModule { 18 | public configure(consumer: MiddlewareConsumer): void | MiddlewareConsumer { 19 | consumer.apply(LoggerMiddleware, CorsMiddleware).forRoutes('*'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS 2 | 3 | ## Description 4 | 5 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 6 | 7 | ## Support 8 | 9 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 10 | 11 | # Mediasoup-NestJS-Example 12 | 13 | ## Dependencies 14 | 15 | * [NodeJS 12.14.1](https://www.ubuntuupdates.org/ppa/nodejs_12.x?dist=bionic) 16 | * [Mediasoup](https://mediasoup.org/) 17 | 18 | ## Installation 19 | 20 | ```bash 21 | npm i 22 | ``` 23 | 24 | ## Running the app 25 | 26 | ### development 27 | 28 | ```bash 29 | npm run start:dev 30 | ``` 31 | 32 | ### beta 33 | 34 | ```bash 35 | npm run build 36 | npm run start:beta 37 | ``` 38 | 39 | ### prod 40 | 41 | ```bash 42 | npm run build 43 | npm run start:prod 44 | ``` 45 | 46 | ## Running the app not on the local machine 47 | 48 | ### Update config.json: 49 | 50 | ```json 51 | "listenIps": [ 52 | { 53 | "ip": "192.168.2.239", // your ip 54 | "announcedIp": null 55 | } 56 | ], 57 | ... 58 | ``` -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { ExpressAdapter } from '@nestjs/platform-express'; 4 | import { IoAdapter } from '@nestjs/platform-socket.io'; 5 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 6 | 7 | import config from 'config'; 8 | import cookieParser from 'cookie-parser'; 9 | import express from 'express'; 10 | import helmet from 'helmet'; 11 | 12 | import { AppModule } from './app.module'; 13 | 14 | const appSettings = config.get('APP_SETTINGS'); 15 | 16 | async function bootstrap() { 17 | const server = express(); 18 | 19 | const app = await NestFactory.create(AppModule, new ExpressAdapter(server)); 20 | 21 | app.use(helmet()); 22 | app.use(cookieParser()); 23 | app.enableCors(); 24 | 25 | app.useGlobalPipes( 26 | new ValidationPipe({ 27 | transform: true, 28 | }) 29 | ); 30 | 31 | app.useWebSocketAdapter(new IoAdapter(app)); 32 | 33 | const options = new DocumentBuilder() 34 | .setTitle('NestJS Mediasoup Example') 35 | .setSchemes(appSettings.swaggerScheme) 36 | .setDescription('The NestJS Mediasoup Example description') 37 | .setVersion('1.0') 38 | .build(); 39 | const document = SwaggerModule.createDocument(app, options); 40 | SwaggerModule.setup('swagger', app, document); 41 | 42 | await app.listen(appSettings.appPort); 43 | } 44 | bootstrap(); 45 | -------------------------------------------------------------------------------- /src/common/helpers/req.helper.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | // tslint:disable: no-feature-envy 4 | export class ReqHelper { 5 | public getIp(req: Request): string { 6 | return req.ip || (req.connection && req.connection.remoteAddress) || '-'; 7 | } 8 | 9 | public getUrl(req: Request): string { 10 | return req.originalUrl || req.url || req.baseUrl || '-'; 11 | } 12 | 13 | public getHttpVersion(req: Request): string { 14 | return req.httpVersionMajor + '.' + req.httpVersionMinor; 15 | } 16 | 17 | public getResponseHeader(res: Response, field: string) { 18 | if (!res.headersSent) { 19 | return undefined; 20 | } 21 | 22 | const header = res.getHeader(field); 23 | 24 | return Array.isArray(header) ? header.join(', ') : header || '-'; 25 | } 26 | 27 | public getReferrer(req: Request) { 28 | const referer = req.headers.referer || req.headers.referrer || '-'; 29 | 30 | if (typeof referer === 'string') { 31 | return referer; 32 | } 33 | 34 | return referer[0]; 35 | } 36 | 37 | public getOrigin(req: Request) { 38 | const origin = req.headers.origin; 39 | 40 | if (!origin || typeof origin === 'string') { 41 | return origin; 42 | } 43 | 44 | return origin[0]; 45 | } 46 | 47 | public getMethod(req: Request) { 48 | return req.method; 49 | } 50 | 51 | public getUserAgent(req: Request) { 52 | return req.headers['user-agent'] || '-'; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/wss/wss.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param } from '@nestjs/common'; 2 | 3 | import pidusage from 'pidusage'; 4 | 5 | import { throwNOTFOUND } from '../common/errors'; 6 | 7 | import { WssGateway } from './wss.gateway'; 8 | 9 | @Controller('websocket') 10 | export class WssController { 11 | constructor(private readonly wssGateway: WssGateway) {} 12 | 13 | @Get('workers/stats') 14 | public async workersStats() { 15 | const workers = this.wssGateway.workersInfo; 16 | 17 | const usage = await pidusage(Object.keys(workers)); 18 | 19 | Object.keys(workers).forEach(key => { 20 | workers[key].pidInfo = usage[key] || {}; 21 | }); 22 | 23 | return workers; 24 | } 25 | 26 | @Get('rooms/stats') 27 | public async roomsStats() { 28 | return Array.from(this.wssGateway.rooms.values()).map(room => { 29 | return room.stats; 30 | }); 31 | } 32 | 33 | @Get('rooms/:id/stats') 34 | public async roomStats(@Param('id') id: string) { 35 | const room = this.wssGateway.rooms.get(id); 36 | 37 | if (room) { 38 | return room.stats; 39 | } 40 | 41 | throwNOTFOUND(); 42 | } 43 | 44 | @Get('rooms/:id/change_worker') 45 | public async roomChangeWorker(@Param('id') id: string) { 46 | const room = this.wssGateway.rooms.get(id); 47 | 48 | if (room) { 49 | await this.wssGateway.reConfigureMedia(room); 50 | 51 | return { msg: 'ok' }; 52 | } 53 | 54 | throwNOTFOUND(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/wss/wss.interfaces.ts: -------------------------------------------------------------------------------- 1 | import io from 'socket.io'; 2 | 3 | import { Consumer, Producer, WebRtcTransport } from 'mediasoup/lib/types'; 4 | 5 | export interface IClientQuery { 6 | readonly user_id: string; 7 | readonly session_id: string; 8 | readonly device: string; 9 | } 10 | 11 | export interface IClient { 12 | id: string; 13 | io: io.Socket; 14 | media?: IMediasoupClient; 15 | device: string; 16 | } 17 | 18 | export interface IMediasoupClient { 19 | producerVideo?: Producer; 20 | producerAudio?: Producer; 21 | producerTransport?: WebRtcTransport; 22 | consumerTransport?: WebRtcTransport; 23 | consumersVideo?: Map; 24 | consumersAudio?: Map; 25 | } 26 | 27 | export interface IWorkerInfo { 28 | workerIndex: number; 29 | clientsCount: number; 30 | roomsCount: number; 31 | pidInfo?: object; 32 | } 33 | 34 | export interface IMsMessage { 35 | readonly action: 36 | | 'getRouterRtpCapabilities' 37 | | 'createWebRtcTransport' 38 | | 'connectWebRtcTransport' 39 | | 'produce' 40 | | 'consume' 41 | | 'restartIce' 42 | | 'requestConsumerKeyFrame' 43 | | 'getTransportStats' 44 | | 'getProducerStats' 45 | | 'getConsumerStats' 46 | | 'getAudioProducerIds' 47 | | 'getVideoProducerIds' 48 | | 'producerClose' 49 | | 'producerPause' 50 | | 'producerResume' 51 | | 'allProducerClose' 52 | | 'allProducerPause' 53 | | 'allProducerResume'; 54 | readonly data?: object; 55 | } 56 | -------------------------------------------------------------------------------- /src/common/middlewares/cors.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | import config from 'config'; 3 | import { NextFunction, Request, Response } from 'express'; 4 | 5 | import { ReqHelper } from '../helpers/req.helper'; 6 | 7 | const corsSettings = config.get('CORS_SETTINGS'); 8 | 9 | @Injectable() 10 | export class CorsMiddleware extends ReqHelper implements NestMiddleware { 11 | constructor() { 12 | super(); 13 | } 14 | 15 | // tslint:disable-next-line: no-feature-envy 16 | public use(req: Request & { credentials: string | boolean }, res: Response, next: NextFunction) { 17 | const origin = this.getOrigin(req); 18 | 19 | const allowedOrigins = corsSettings.allowedOrigins; 20 | const allowedMethods = corsSettings.allowedMethods; 21 | const allowedHeaders = corsSettings.allowedHeaders; 22 | 23 | const findOrigin = allowedOrigins.find(o => o === origin); 24 | 25 | if (origin && allowedOrigins.length) { 26 | res.setHeader('Access-Control-Allow-Origin', findOrigin || allowedOrigins[0]); 27 | } else { 28 | res.setHeader('Access-Control-Allow-Origin', '*'); 29 | } 30 | 31 | res.setHeader('Access-Control-Allow-Methods', allowedMethods.join(',')); 32 | res.setHeader('Access-Control-Allow-Headers', allowedHeaders.join(',')); 33 | res.setHeader('Access-Control-Allow-Credentials', `${corsSettings.allowedCredentials}`); 34 | res.setHeader('Access-Control-Max-Age', '1728000'); 35 | 36 | return next(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/logger/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import config from 'config'; 3 | 4 | enum ELogLevel { 5 | debug, 6 | info, 7 | warn, 8 | error, 9 | } 10 | 11 | @Injectable() 12 | export class LoggerService extends Logger { 13 | // tslint:disable-next-line: no-unsafe-any 14 | private readonly _currentLevel: ELogLevel = ELogLevel[config.get('LOGGER_SETTINGS').level]; 15 | 16 | constructor(private readonly _context?: string) { 17 | super(_context); 18 | } 19 | 20 | // tslint:disable-next-line: no-any 21 | public log(message: any, context?: string) { 22 | if (this.isValidLevel(ELogLevel.debug)) { 23 | Logger.log(JSON.stringify(message, null, 2), context || this._context); 24 | } 25 | } 26 | // tslint:disable-next-line: no-any 27 | public info(message: any, context?: string) { 28 | if (this.isValidLevel(ELogLevel.info)) { 29 | Logger.log(JSON.stringify(message, null, 2), context || this._context); 30 | } 31 | } 32 | // tslint:disable-next-line: no-any 33 | public warn(message: any, context?: string) { 34 | if (this.isValidLevel(ELogLevel.warn)) { 35 | Logger.warn(JSON.stringify(message, null, 2), context || this._context); 36 | } 37 | } 38 | // tslint:disable-next-line: no-any 39 | public error(message: any, trace?: string, context?: string) { 40 | if (this.isValidLevel(ELogLevel.error)) { 41 | Logger.error(JSON.stringify(message, null, 2), trace, context || this._context); 42 | } 43 | } 44 | 45 | private isValidLevel(level: ELogLevel): boolean { 46 | return level >= this._currentLevel; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /types/global.d.ts: -------------------------------------------------------------------------------- 1 | interface IClient { 2 | readonly client_id: string; 3 | readonly client_secret: string; 4 | } 5 | 6 | interface ILogSettings { 7 | readonly level: string; 8 | readonly silence: string[]; 9 | } 10 | 11 | interface IAppSettings { 12 | readonly appPort: number; 13 | readonly wssPort: number; 14 | readonly swaggerScheme: 'http' | 'https'; 15 | readonly client: IClient; 16 | readonly sslCrt: string; 17 | readonly sslKey: string; 18 | } 19 | 20 | interface ICorsSettings { 21 | readonly allowedOrigins: string[]; 22 | readonly allowedMethods: string[]; 23 | readonly allowedCredentials: boolean; 24 | readonly allowedHeaders: string[]; 25 | } 26 | 27 | interface IMediasoupWorkerSettings { 28 | readonly rtcMinPort: number; 29 | readonly rtcMaxPort: number; 30 | readonly logLevel: string; 31 | readonly logTags: string[]; 32 | } 33 | 34 | interface IMediasoupMediacodecSettings { 35 | readonly kind: string; 36 | readonly mimeType: string; 37 | readonly clockRate: number; 38 | readonly channels?: number; 39 | readonly preferredPayloadType?: number; 40 | readonly rtcpFeedback?: IMediasoupRtcpFeedback[]; 41 | readonly parameters?: { "x-google-start-bitrate": number } 42 | } 43 | 44 | interface IMediasoupListenIds { 45 | readonly ip: string; 46 | readonly announcedIp?: string | null; 47 | } 48 | 49 | interface IMediasoupWebRtcTransport { 50 | readonly listenIps: IMediasoupListenIds[]; 51 | readonly initialAvailableOutgoingBitrate: number; 52 | readonly minimumAvailableOutgoingBitrate: number; 53 | readonly maximumAvailableOutgoingBitrate: number; 54 | readonly factorIncomingBitrate: number; 55 | } 56 | 57 | interface IMediasoupSettings { 58 | readonly workerPool: number; 59 | readonly worker: IMediasoupWorkerSettings; 60 | readonly router: { mediaCodecs: IMediasoupMediacodecSettings[] } 61 | readonly webRtcTransport: IMediasoupWebRtcTransport; 62 | } -------------------------------------------------------------------------------- /src/common/middlewares/logger.moddleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | import config from 'config'; 3 | import { NextFunction, Request, Response } from 'express'; 4 | 5 | import { ReqHelper } from '../helpers/req.helper'; 6 | 7 | import { LoggerService } from '../../logger/logger.service'; 8 | 9 | @Injectable() 10 | export class LoggerMiddleware extends ReqHelper implements NestMiddleware { 11 | private readonly _settings: ILogSettings = config.get('LOGGER_SETTINGS'); 12 | 13 | constructor(private readonly logger: LoggerService) { 14 | super(); 15 | } 16 | 17 | public use(req: Request, res: Response, next: NextFunction) { 18 | const action = this.getUrl(req).split('/')[1]; 19 | if (this._settings.silence.includes(action)) { 20 | return next(); 21 | } 22 | 23 | req.on('error', (error: Error) => { 24 | this.logMethodByStatus(error.message, error.stack, req.statusCode); 25 | }); 26 | 27 | res.on('error', (error: Error) => { 28 | this.logMethodByStatus(error.message, error.stack, res.statusCode); 29 | }); 30 | 31 | res.on('finish', () => { 32 | const message = { 33 | path: `${req.method} ${this.getUrl(req)}`, 34 | referrer: this.getReferrer(req), 35 | userAgent: this.getUserAgent(req), 36 | remoteAddress: this.getIp(req), 37 | status: `${res.statusCode} ${res.statusMessage}`, 38 | }; 39 | 40 | this.logMethodByStatus(message, '', res.statusCode); 41 | }); 42 | 43 | return next(); 44 | } 45 | 46 | // tslint:disable-next-line: no-any 47 | private logMethodByStatus(message: any, stack: string, statusCode: number = 500) { 48 | const prefix = 'LoggerMiddleware'; 49 | if (statusCode < 300) { 50 | return this.logger.info(message, prefix); 51 | } else if (statusCode < 400) { 52 | return this.logger.warn(message, prefix); 53 | } else { 54 | return this.logger.error(message, stack, prefix); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "LOGGER_SETTINGS": { 3 | "level": "debug", 4 | "silence": [ 5 | "healthz" 6 | ] 7 | }, 8 | "APP_SETTINGS": { 9 | "appPort": 8085, 10 | "wssPort": 8086, 11 | "swaggerScheme": "http", 12 | "client": { 13 | "client_id": "", 14 | "client_secret": "" 15 | }, 16 | "sslCrt": "", 17 | "sslKey": "" 18 | }, 19 | "CORS_SETTINGS": { 20 | "allowedOrigins": [], 21 | "allowedMethods": [ 22 | "GET", 23 | "POST", 24 | "PUT", 25 | "PATCH", 26 | "OPTIONS" 27 | ], 28 | "allowedCredentials": false, 29 | "allowedHeaders": [ 30 | "Content-Type", 31 | "Content-Language", 32 | "Authorization", 33 | "X-Authorization", 34 | "Origin", 35 | "Accept", 36 | "Accept-Language" 37 | ] 38 | }, 39 | "MEDIASOUP_SETTINGS": { 40 | "workerPool": 3, 41 | "worker": { 42 | "rtcMinPort": 10000, 43 | "rtcMaxPort": 10100, 44 | "logLevel": "warn", 45 | "logTags": [ 46 | "info", 47 | "ice", 48 | "dtls", 49 | "rtp", 50 | "srtp", 51 | "rtcp" 52 | ] 53 | }, 54 | "router": { 55 | "mediaCodecs": [ 56 | { 57 | "kind": "audio", 58 | "mimeType": "audio/opus", 59 | "clockRate": 48000, 60 | "channels": 2 61 | }, 62 | { 63 | "kind": "video", 64 | "mimeType": "video/VP8", 65 | "clockRate": 90000, 66 | "parameters": { 67 | "x-google-start-bitrate": 1000 68 | } 69 | } 70 | ] 71 | }, 72 | "webRtcTransport": { 73 | "listenIps": [ 74 | { 75 | "ip": "127.0.0.1", 76 | "announcedIp": null 77 | } 78 | ], 79 | "initialAvailableOutgoingBitrate": 100000, 80 | "minimumAvailableOutgoingBitrate": 15000, 81 | "maximumAvailableOutgoingBitrate": 200000, 82 | "factorIncomingBitrate": 0.75 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /src/common/errors/index.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export const throwFORBIDDEN = (msg: string | null = null) => { 4 | throw new HttpException( 5 | { 6 | status: HttpStatus.FORBIDDEN, 7 | error: 8 | msg || 'Invalid authorization data specified in the request, or access to the requested resource is forbidden.', 9 | }, 10 | HttpStatus.FORBIDDEN 11 | ); 12 | }; 13 | 14 | export const throwUNAUTHORIZED = (msg: string | null = null) => { 15 | throw new HttpException( 16 | { 17 | status: HttpStatus.UNAUTHORIZED, 18 | error: msg || 'Authorization data was not specified in the request', 19 | }, 20 | HttpStatus.UNAUTHORIZED 21 | ); 22 | }; 23 | 24 | export const throwBADREQUEST = (msg: string | null = null) => { 25 | throw new HttpException( 26 | { 27 | status: HttpStatus.BAD_REQUEST, 28 | error: msg || 'Bad Request', 29 | }, 30 | HttpStatus.BAD_REQUEST 31 | ); 32 | }; 33 | 34 | export const throwNOTFOUND = (msg: string | null = null) => { 35 | throw new HttpException( 36 | { 37 | status: HttpStatus.NOT_FOUND, 38 | error: msg || 'Not Found', 39 | }, 40 | HttpStatus.NOT_FOUND 41 | ); 42 | }; 43 | 44 | export const throwMETHODNOTALLOWED = (msg: string | null = null) => { 45 | throw new HttpException( 46 | { 47 | status: HttpStatus.METHOD_NOT_ALLOWED, 48 | error: msg || 'Method is not allowed access', 49 | }, 50 | HttpStatus.METHOD_NOT_ALLOWED 51 | ); 52 | }; 53 | 54 | export const throwINTERNALSERVERERROR = (msg: string | null = null) => { 55 | throw new HttpException( 56 | { 57 | status: HttpStatus.INTERNAL_SERVER_ERROR, 58 | error: msg || 'Internal server error', 59 | }, 60 | HttpStatus.INTERNAL_SERVER_ERROR 61 | ); 62 | }; 63 | 64 | export const throwSERVICEUNAVAILABLE = (msg: string | null = null) => { 65 | throw new HttpException( 66 | { 67 | status: HttpStatus.SERVICE_UNAVAILABLE, 68 | error: msg || 'Service unvailable', 69 | }, 70 | HttpStatus.SERVICE_UNAVAILABLE 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mediasoup-nestjs-example", 3 | "version": "1.0.0", 4 | "description": "NestJS Mediasoup Example", 5 | "author": "t.kosminov", 6 | "license": "MIT", 7 | "engineStrict": true, 8 | "engines": { 9 | "node": "12.14.1" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/TimurRK/mediasoup-nestjs-example.git" 14 | }, 15 | "scripts": { 16 | "build": "rimraf dist && tsc -p tsconfig.build.json", 17 | "format": "prettier --write \"src/**/*.ts\"", 18 | "start": "ts-node --files -r tsconfig-paths/register src/main.ts", 19 | "start:debug": "nodemon --config nodemon-debug.json", 20 | "start:dev": "nodemon", 21 | "start:build": "NODE_ENV=development node dist/main.js", 22 | "start:beta": "NODE_ENV=beta node dist/main.js", 23 | "start:prod": "NODE_ENV=production node dist/main.js", 24 | "lint": "tslint -p tsconfig.json -c tslint.json", 25 | "lint-fix": "npm run lint -- --fix" 26 | }, 27 | "husky": { 28 | "hooks": { 29 | "pre-commit": "npm run lint" 30 | } 31 | }, 32 | "dependencies": { 33 | "@nestjs/common": "^6.11.4", 34 | "@nestjs/core": "^6.11.4", 35 | "@nestjs/platform-express": "^6.11.4", 36 | "@nestjs/platform-socket.io": "^6.11.4", 37 | "@nestjs/swagger": "^3.1.0", 38 | "@nestjs/websockets": "^6.11.4", 39 | "class-transformer": "^0.2.3", 40 | "class-validator": "^0.9.1", 41 | "config": "^3.2.5", 42 | "cookie-parser": "^1.4.4", 43 | "helmet": "^3.21.2", 44 | "mediasoup": "^3.4.8", 45 | "pidusage": "^2.0.17", 46 | "query-string": "^6.10.1", 47 | "reflect-metadata": "^0.1.13", 48 | "rimraf": "^2.7.1", 49 | "rxjs": "^6.5.4", 50 | "socket.io": "^2.3.0", 51 | "swagger-ui-express": "^4.1.3" 52 | }, 53 | "devDependencies": { 54 | "@babel/core": "^7.8.3", 55 | "@types/config": "0.0.34", 56 | "@types/cookie-parser": "^1.4.2", 57 | "@types/cors": "^2.8.6", 58 | "@types/express": "^4.17.2", 59 | "@types/helmet": "0.0.42", 60 | "@types/node": "^11.15.5", 61 | "@types/pidusage": "^2.0.1", 62 | "@types/query-string": "^6.3.0", 63 | "@types/socket.io": "^2.1.4", 64 | "husky": "^2.7.0", 65 | "nodemon": "^1.19.4", 66 | "prettier": "^1.19.1", 67 | "ts-loader": "^4.4.2", 68 | "ts-node": "^8.6.2", 69 | "tsconfig-paths": "^3.9.0", 70 | "tslint": "5.12.1", 71 | "tslint-clean-code": "^0.2.10", 72 | "tslint-config-prettier": "^1.18.0", 73 | "tslint-eslint-rules": "^5.4.0", 74 | "tslint-plugin-prettier": "^2.1.0", 75 | "typescript": "^3.7.5" 76 | }, 77 | "jest": { 78 | "moduleFileExtensions": [ 79 | "js", 80 | "json", 81 | "ts" 82 | ], 83 | "rootDir": "src", 84 | "testRegex": ".spec.ts$", 85 | "transform": { 86 | "^.+\\.(t|j)s$": "ts-jest" 87 | }, 88 | "coverageDirectory": "../coverage", 89 | "testEnvironment": "node" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-clean-code", 6 | "tslint-eslint-rules", 7 | "tslint-config-prettier" 8 | ], 9 | "rulesDirectory": [ 10 | "./rules", 11 | "tslint-plugin-prettier" 12 | ], 13 | "linterOptions": { 14 | "exclude": [ 15 | "./test/**/*", 16 | "./**/*spec.ts", 17 | "./dist/**/*", 18 | "./node_modules/**/*", 19 | "./rules/**/*", 20 | "./config/**/*" 21 | ] 22 | }, 23 | "rules": { 24 | "arrow-return-shorthand": true, 25 | "callable-types": true, 26 | "class-name": true, 27 | "comment-format": [ 28 | false, 29 | "check-space" 30 | ], 31 | "curly": true, 32 | "eofline": true, 33 | "forin": true, 34 | "import-blacklist": [ 35 | true 36 | ], 37 | "import-spacing": true, 38 | "indent": [ 39 | true, 40 | "spaces", 41 | 2 42 | ], 43 | "interface-over-type-literal": true, 44 | "label-position": true, 45 | "max-line-length": [ 46 | true, 47 | 120 48 | ], 49 | "member-access": true, 50 | "member-ordering": [ 51 | true, 52 | { 53 | "order": [ 54 | "static-field", 55 | "instance-field", 56 | "static-method", 57 | "instance-method" 58 | ] 59 | } 60 | ], 61 | "no-arg": true, 62 | "no-bitwise": true, 63 | "no-construct": true, 64 | "no-debugger": true, 65 | "no-duplicate-super": true, 66 | "no-empty": true, 67 | "no-empty-interface": true, 68 | "no-eval": true, 69 | "no-inferrable-types": [ 70 | true, 71 | "ignore-params", 72 | "ignore-properties" 73 | ], 74 | "no-misused-new": true, 75 | "no-non-null-assertion": true, 76 | "no-shadowed-variable": true, 77 | "no-string-literal": true, 78 | "no-string-throw": true, 79 | "no-switch-case-fall-through": false, 80 | "no-trailing-whitespace": true, 81 | "no-unnecessary-initializer": true, 82 | "no-unused-expression": true, 83 | "no-use-before-declare": true, 84 | "no-var-keyword": true, 85 | "object-literal-sort-keys": false, 86 | "one-line": [ 87 | true, 88 | "check-open-brace", 89 | "check-catch", 90 | "check-else" 91 | ], 92 | "prefer-const": true, 93 | "quotemark": [ 94 | true, 95 | "single" 96 | ], 97 | "radix": true, 98 | "semicolon": [ 99 | true, 100 | "always" 101 | ], 102 | "triple-equals": [ 103 | true, 104 | "allow-null-check" 105 | ], 106 | "typedef-whitespace": [ 107 | true, 108 | { 109 | "call-signature": "nospace", 110 | "index-signature": "nospace", 111 | "parameter": "nospace", 112 | "property-declaration": "nospace", 113 | "variable-declaration": "nospace" 114 | } 115 | ], 116 | "unified-signatures": true, 117 | "whitespace": [ 118 | true, 119 | "check-branch", 120 | "check-decl", 121 | "check-operator", 122 | "check-separator", 123 | "check-type", 124 | "check-preblock", 125 | "check-type-operator" 126 | ], 127 | "no-invalid-template-strings": true, 128 | "interface-name": true, 129 | "array-type": [ 130 | true, 131 | "array-simple" 132 | ], 133 | "align": true, 134 | "prefer-readonly": true, 135 | "ordered-imports": true, 136 | "no-consecutive-blank-lines": true, 137 | "trailing-comma": [ 138 | true, 139 | { 140 | "multiline": { 141 | "objects": "always", 142 | "arrays": "always", 143 | "functions": "never", 144 | "typeLiterals": "ignore" 145 | }, 146 | "esSpecCompliant": true 147 | } 148 | ], 149 | "space-before-function-paren": [ 150 | true, 151 | { 152 | "anonymous": "never", 153 | "named": "never", 154 | "asyncArrow": "always" 155 | } 156 | ], 157 | "no-any": true, 158 | "no-unsafe-any": false, 159 | "only-arrow-functions": false, 160 | "variable-name": [ 161 | true, 162 | "ban-keywords", 163 | "check-format", 164 | "allow-leading-underscore", 165 | "allow-snake-case" 166 | ], 167 | "try-catch-first": true, 168 | "max-func-args": [ 169 | true, 170 | 4 171 | ], 172 | "no-flag-args": true, 173 | "no-for-each-push": true, 174 | "no-feature-envy": [ 175 | false, 176 | 1, 177 | [ 178 | "_" 179 | ] 180 | ], 181 | "no-map-without-usage": true, 182 | "no-complex-conditionals": true, 183 | "prefer-dry-conditionals": true, 184 | "no-constant-condition": true, 185 | "no-control-regex": true, 186 | "no-duplicate-case": true, 187 | "no-empty-character-class": true, 188 | "no-ex-assign": true, 189 | "no-extra-semi": true, 190 | "no-inner-declarations": true, 191 | "no-invalid-regexp": true, 192 | "valid-jsdoc-custom": [ 193 | true, 194 | { 195 | "requireReturn": true, 196 | "requireParamType": false, 197 | "requireParamDescription": true, 198 | "matchDescription": "^[A-ZА-Я][A-ZА-Яa-zа-я0-9\\s]*[.]$" 199 | } 200 | ], 201 | "valid-typeof": true, 202 | "prettier": true 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/wss/wss.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OnGatewayConnection, 3 | OnGatewayDisconnect, 4 | SubscribeMessage, 5 | WebSocketGateway, 6 | WebSocketServer, 7 | } from '@nestjs/websockets'; 8 | 9 | import config from 'config'; 10 | import io from 'socket.io'; 11 | 12 | import * as mediasoup from 'mediasoup'; 13 | import Worker, { WorkerSettings } from 'mediasoup/lib/Worker'; 14 | 15 | import { LoggerService } from '../logger/logger.service'; 16 | 17 | import { IClientQuery, IMsMessage, IWorkerInfo } from './wss.interfaces'; 18 | import { WssRoom } from './wss.room'; 19 | 20 | const appSettings = config.get('APP_SETTINGS'); 21 | const mediasoupSettings = config.get('MEDIASOUP_SETTINGS'); 22 | 23 | @WebSocketGateway(appSettings.wssPort) 24 | export class WssGateway implements OnGatewayConnection, OnGatewayDisconnect { 25 | @WebSocketServer() 26 | public server: io.Server; 27 | 28 | public rooms: Map = new Map(); 29 | 30 | public workers: { [index: number]: { clientsCount: number; roomsCount: number; pid: number; worker: Worker } }; 31 | 32 | constructor(private readonly logger: LoggerService) { 33 | this.createWorkers(); 34 | } 35 | 36 | get workersInfo() { 37 | this.updateWorkerStats(); 38 | 39 | return Object.fromEntries( 40 | Object.entries(this.workers).map(w => { 41 | return [ 42 | w[1].pid, 43 | { 44 | workerIndex: parseInt(w[0], 10), 45 | clientsCount: w[1].clientsCount, 46 | roomsCount: w[1].roomsCount, 47 | }, 48 | ]; 49 | }) 50 | ) as { [pid: string]: IWorkerInfo }; 51 | } 52 | 53 | /** 54 | * Создает воркеры медиасупа. 55 | * @returns {Promise} Promise 56 | */ 57 | private async createWorkers(): Promise { 58 | const promises = []; 59 | for (let i = 0; i < mediasoupSettings.workerPool; i++) { 60 | promises.push(mediasoup.createWorker(mediasoupSettings.worker as WorkerSettings)); 61 | } 62 | 63 | this.workers = (await Promise.all(promises)).reduce((acc, worker, index) => { 64 | acc[index] = { 65 | clientsCount: 0, 66 | roomsCount: 0, 67 | pid: worker.pid, 68 | worker, 69 | }; 70 | 71 | return acc; 72 | }, {}); 73 | } 74 | 75 | /** 76 | * Обновляет инфу о количество пользователей на веркере. 77 | * @returns {void} void 78 | */ 79 | public updateWorkerStats(): void { 80 | const data: { [index: number]: { clientsCount: number; roomsCount: number } } = {}; 81 | 82 | this.rooms.forEach(room => { 83 | if (data[room.workerIndex]) { 84 | data[room.workerIndex].clientsCount += room.clientsCount; 85 | data[room.workerIndex].roomsCount += 1; 86 | } else { 87 | data[room.workerIndex] = { 88 | clientsCount: room.clientsCount, 89 | roomsCount: 1, 90 | }; 91 | } 92 | }); 93 | 94 | Object.entries(this.workers).forEach(([index, _worker]) => { 95 | const info = data[index]; 96 | if (info) { 97 | this.workers[index].clientsCount = info.clientsCount; 98 | this.workers[index].roomsCount = info.roomsCount; 99 | } else { 100 | this.workers[index].clientsCount = 0; 101 | this.workers[index].roomsCount = 0; 102 | } 103 | }); 104 | } 105 | 106 | /** 107 | * Возвращает номер воркер с наименьшим количеством участников. 108 | * @returns {number} number 109 | */ 110 | private getOptimalWorkerIndex(): number { 111 | return parseInt( 112 | Object.entries(this.workers).reduce((prev, curr) => { 113 | if (prev[1].clientsCount < curr[1].clientsCount) { 114 | return prev; 115 | } 116 | return curr; 117 | })[0], 118 | 10 119 | ); 120 | } 121 | 122 | private getClientQuery(client: io.Socket): IClientQuery { 123 | return client.handshake.query as IClientQuery; 124 | } 125 | 126 | public async handleConnection(client: io.Socket) { 127 | try { 128 | const query = this.getClientQuery(client); 129 | 130 | let room = this.rooms.get(query.session_id); 131 | 132 | if (!room) { 133 | this.updateWorkerStats(); 134 | 135 | const index = this.getOptimalWorkerIndex(); 136 | 137 | room = new WssRoom(this.workers[index].worker, index, query.session_id, this.logger, this.server); 138 | 139 | await room.load(); 140 | 141 | this.rooms.set(query.session_id, room); 142 | 143 | this.logger.info(`room ${query.session_id} created`); 144 | } 145 | 146 | await room.addClient(query, client); 147 | 148 | return true; 149 | } catch (error) { 150 | this.logger.error(error.message, error.stack, 'WssGateway - handleConnection'); 151 | } 152 | } 153 | 154 | public async handleDisconnect(client: io.Socket) { 155 | try { 156 | const { user_id, session_id } = this.getClientQuery(client); 157 | 158 | const room = this.rooms.get(session_id); 159 | 160 | await room.removeClient(user_id); 161 | 162 | if (!room.clientsCount) { 163 | room.close(); 164 | this.rooms.delete(session_id); 165 | } 166 | 167 | return true; 168 | } catch (error) { 169 | this.logger.error(error.message, error.stack, 'WssGateway - handleDisconnect'); 170 | } 171 | } 172 | 173 | @SubscribeMessage('mediaRoomClients') 174 | public async roomClients(client: io.Socket) { 175 | try { 176 | const { session_id } = this.getClientQuery(client); 177 | 178 | const room = this.rooms.get(session_id); 179 | 180 | return { 181 | clientsIds: room.clientsIds, 182 | producerAudioIds: room.audioProducerIds, 183 | producerVideoIds: room.videoProducerIds, 184 | }; 185 | } catch (error) { 186 | this.logger.error(error.message, error.stack, 'WssGateway - roomClients'); 187 | } 188 | } 189 | 190 | @SubscribeMessage('mediaRoomInfo') 191 | public async roomInfo(client: io.Socket) { 192 | try { 193 | const { session_id } = this.getClientQuery(client); 194 | 195 | const room = this.rooms.get(session_id); 196 | 197 | return room.stats; 198 | } catch (error) { 199 | this.logger.error(error.message, error.stack, 'WssGateway - roomInfo'); 200 | } 201 | } 202 | 203 | @SubscribeMessage('media') 204 | public async media(client: io.Socket, msg: IMsMessage) { 205 | try { 206 | const { user_id, session_id } = this.getClientQuery(client); 207 | 208 | const room = this.rooms.get(session_id); 209 | 210 | return await room.speakMsClient(user_id, msg); 211 | } catch (error) { 212 | this.logger.error(error.message, error.stack, 'WssGateway - media'); 213 | } 214 | } 215 | 216 | @SubscribeMessage('mediaReconfigure') 217 | public async roomReconfigure(client: io.Socket) { 218 | try { 219 | const { session_id } = this.getClientQuery(client); 220 | 221 | const room = this.rooms.get(session_id); 222 | 223 | if (room) { 224 | await this.reConfigureMedia(room); 225 | } 226 | 227 | return true; 228 | } catch (error) { 229 | this.logger.error(error.message, error.stack, 'WssGateway - roomReconfigure'); 230 | } 231 | } 232 | 233 | /** 234 | * Меняет воркер у комнаты. 235 | * @param {WssRoom} room комната 236 | * @returns {Promise} Promise 237 | */ 238 | public async reConfigureMedia(room: WssRoom): Promise { 239 | try { 240 | this.updateWorkerStats(); 241 | 242 | const index = this.getOptimalWorkerIndex(); 243 | 244 | await room.reConfigureMedia(this.workers[index].worker, index); 245 | } catch (error) { 246 | this.logger.error(error.message, error.stack, 'WssGateway - reConfigureMedia'); 247 | } 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /rules/validJsdocCustomRule.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | var tslib_1 = require("tslib"); 4 | var ts = require("typescript"); 5 | var Lint = require("tslint"); 6 | var doctrine = require("doctrine"); 7 | var RULE_NAME = 'valid-jsdoc-custom'; 8 | var OPTIONS; 9 | var Rule = (function (_super) { 10 | tslib_1.__extends(Rule, _super); 11 | function Rule() { 12 | return _super !== null && _super.apply(this, arguments) || this; 13 | } 14 | Rule.prototype.apply = function (sourceFile) { 15 | var opts = this.getOptions().ruleArguments; 16 | OPTIONS = { 17 | prefer: {}, 18 | requireReturn: true, 19 | requireParamType: true, 20 | requireReturnType: true, 21 | requireParamDescription: true, 22 | requireReturnDescription: true, 23 | matchDescription: '' 24 | }; 25 | if (opts && opts.length > 0) { 26 | if (opts[0].prefer) { 27 | OPTIONS.prefer = opts[0].prefer; 28 | } 29 | OPTIONS.requireReturn = opts[0].requireReturn !== false; 30 | OPTIONS.requireParamType = opts[0].requireParamType !== false; 31 | OPTIONS.requireReturnType = opts[0].requireReturnType !== false; 32 | OPTIONS.requireParamDescription = opts[0].requireParamDescription !== false; 33 | OPTIONS.requireReturnDescription = opts[0].requireReturnDescription !== false; 34 | OPTIONS.matchDescription = opts[0].matchDescription; 35 | } 36 | var walker = new ValidJsdocWalker(sourceFile, this.getOptions()); 37 | return this.applyWithWalker(walker); 38 | }; 39 | Rule.FAILURE_STRING = { 40 | missingBrace: 'JSDoc type missing brace', 41 | syntaxError: 'JSDoc syntax error', 42 | missingParameterType: function (name) { return "missing JSDoc parameter type for '" + name + "'"; }, 43 | missingParameterDescription: function (name) { return "missing JSDoc parameter description for '" + name + "'"; }, 44 | duplicateParameter: function (name) { return "duplicate JSDoc parameter '" + name + "'"; }, 45 | unexpectedTag: function (title) { return "unexpected @" + title + " tag; function has no return statement"; }, 46 | missingReturnType: 'missing JSDoc return type', 47 | missingReturnDescription: 'missing JSDoc return description', 48 | prefer: function (name) { return "use @" + name + " instead"; }, 49 | missingReturn: function (param) { return "missing JSDoc @" + (param || 'returns') + " for function"; }, 50 | wrongParam: function (expected, actual) { return "expected JSDoc for '" + expected + "' but found '" + actual + "'"; }, 51 | missingParam: function (name) { return "missing JSDoc for parameter '" + name + "'"; }, 52 | wrongDescription: 'JSDoc description does not satisfy the regex pattern', 53 | invalidRegexDescription: function (error) { return "configured matchDescription is an invalid RegExp. Error: " + error; } 54 | }; 55 | Rule.metadata = { 56 | ruleName: RULE_NAME, 57 | hasFix: false, 58 | description: 'enforce valid JSDoc comments', 59 | rationale: Lint.Utils.dedent(templateObject_1 || (templateObject_1 = tslib_1.__makeTemplateObject(["\n [JSDoc](http://usejsdoc.org/) generates application programming interface (API) documentation\n from specially-formatted comments in JavaScript code. So does [typedoc](http://typedoc.org/).\n\n If comments are invalid because of typing mistakes, then documentation will be incomplete.\n\n If comments are inconsistent because they are not updated when function definitions are\n modified, then readers might become confused.\n "], ["\n [JSDoc](http://usejsdoc.org/) generates application programming interface (API) documentation\n from specially-formatted comments in JavaScript code. So does [typedoc](http://typedoc.org/).\n\n If comments are invalid because of typing mistakes, then documentation will be incomplete.\n\n If comments are inconsistent because they are not updated when function definitions are\n modified, then readers might become confused.\n "]))), 60 | optionsDescription: Lint.Utils.dedent(templateObject_2 || (templateObject_2 = tslib_1.__makeTemplateObject(["\n This rule has an object option:\n\n * `\"prefer\"` enforces consistent documentation tags specified by an object whose properties\n mean instead of key use value (for example, `\"return\": \"returns\"` means\n instead of `@return` use `@returns`)\n * `\"preferType\"` enforces consistent type strings specified by an object whose properties\n mean instead of key use value (for example, `\"object\": \"Object\"` means\n instead of `object` use `Object`)\n * `\"requireReturn\"` requires a return tag:\n * `true` (default) *even if* the function or method does not have a return statement\n (this option value does not apply to constructors)\n * `false` *if and only if* the function or method has a return statement (this option\n value does apply to constructors)\n * `\"requireParamType\"`: `false` allows missing type in param tags\n * `\"requireReturnType\"`: `false` allows missing type in return tags\n * `\"matchDescription\"` specifies (as a string) a regular expression to match the description\n in each JSDoc comment (for example, `\".+\"` requires a description;\n this option does not apply to descriptions in parameter or return\n tags)\n * `\"requireParamDescription\"`: `false` allows missing description in parameter tags\n * `\"requireReturnDescription\"`: `false` allows missing description in return tags\n "], ["\n This rule has an object option:\n\n * \\`\"prefer\"\\` enforces consistent documentation tags specified by an object whose properties\n mean instead of key use value (for example, \\`\"return\": \"returns\"\\` means\n instead of \\`@return\\` use \\`@returns\\`)\n * \\`\"preferType\"\\` enforces consistent type strings specified by an object whose properties\n mean instead of key use value (for example, \\`\"object\": \"Object\"\\` means\n instead of \\`object\\` use \\`Object\\`)\n * \\`\"requireReturn\"\\` requires a return tag:\n * \\`true\\` (default) *even if* the function or method does not have a return statement\n (this option value does not apply to constructors)\n * \\`false\\` *if and only if* the function or method has a return statement (this option\n value does apply to constructors)\n * \\`\"requireParamType\"\\`: \\`false\\` allows missing type in param tags\n * \\`\"requireReturnType\"\\`: \\`false\\` allows missing type in return tags\n * \\`\"matchDescription\"\\` specifies (as a string) a regular expression to match the description\n in each JSDoc comment (for example, \\`\".+\"\\` requires a description;\n this option does not apply to descriptions in parameter or return\n tags)\n * \\`\"requireParamDescription\"\\`: \\`false\\` allows missing description in parameter tags\n * \\`\"requireReturnDescription\"\\`: \\`false\\` allows missing description in return tags\n "]))), 61 | options: { 62 | type: 'object', 63 | properties: { 64 | prefer: { 65 | type: 'object', 66 | additionalProperties: { 67 | type: 'string' 68 | } 69 | }, 70 | preferType: { 71 | type: 'object', 72 | additionalProperties: { 73 | type: 'string' 74 | } 75 | }, 76 | requireReturn: { 77 | type: 'boolean' 78 | }, 79 | requireParamDescription: { 80 | type: 'boolean' 81 | }, 82 | requireReturnDescription: { 83 | type: 'boolean' 84 | }, 85 | matchDescription: { 86 | type: 'string' 87 | }, 88 | requireParamType: { 89 | type: 'boolean' 90 | }, 91 | requireReturnType: { 92 | type: 'boolean' 93 | } 94 | }, 95 | additionalProperties: false 96 | }, 97 | optionExamples: [ 98 | Lint.Utils.dedent(templateObject_3 || (templateObject_3 = tslib_1.__makeTemplateObject(["\n \"", "\": [true]\n "], ["\n \"", "\": [true]\n "])), RULE_NAME), 99 | Lint.Utils.dedent(templateObject_4 || (templateObject_4 = tslib_1.__makeTemplateObject(["\n \"", "\": [true, {\n \"prefer\": {\n \"return\": \"returns\"\n },\n \"requireReturn\": false,\n \"requireParamDescription\": true,\n \"requireReturnDescription\": true,\n \"matchDescription\": \"^[A-Z][A-Za-z0-9\\\\s]*[.]$\"\n }]\n "], ["\n \"", "\": [true, {\n \"prefer\": {\n \"return\": \"returns\"\n },\n \"requireReturn\": false,\n \"requireParamDescription\": true,\n \"requireReturnDescription\": true,\n \"matchDescription\": \"^[A-Z][A-Za-z0-9\\\\\\\\s]*[.]$\"\n }]\n "])), RULE_NAME) 100 | ], 101 | typescriptOnly: false, 102 | type: 'maintainability' 103 | }; 104 | return Rule; 105 | }(Lint.Rules.AbstractRule)); 106 | exports.Rule = Rule; 107 | var ValidJsdocWalker = (function (_super) { 108 | tslib_1.__extends(ValidJsdocWalker, _super); 109 | function ValidJsdocWalker() { 110 | var _this = _super !== null && _super.apply(this, arguments) || this; 111 | _this.fns = []; 112 | return _this; 113 | } 114 | ValidJsdocWalker.prototype.visitSourceFile = function (node) { 115 | _super.prototype.visitSourceFile.call(this, node); 116 | }; 117 | ValidJsdocWalker.prototype.visitNode = function (node) { 118 | if (node.kind === ts.SyntaxKind.ClassExpression) { 119 | this.visitClassExpression(node); 120 | } 121 | else { 122 | _super.prototype.visitNode.call(this, node); 123 | } 124 | }; 125 | ValidJsdocWalker.prototype.visitArrowFunction = function (node) { 126 | this.startFunction(node); 127 | _super.prototype.visitArrowFunction.call(this, node); 128 | this.checkJSDoc(node); 129 | }; 130 | ValidJsdocWalker.prototype.visitFunctionExpression = function (node) { 131 | this.startFunction(node); 132 | _super.prototype.visitFunctionExpression.call(this, node); 133 | this.checkJSDoc(node); 134 | }; 135 | ValidJsdocWalker.prototype.visitFunctionDeclaration = function (node) { 136 | this.startFunction(node); 137 | _super.prototype.visitFunctionDeclaration.call(this, node); 138 | this.checkJSDoc(node); 139 | }; 140 | ValidJsdocWalker.prototype.visitClassExpression = function (node) { 141 | this.startFunction(node); 142 | _super.prototype.visitClassExpression.call(this, node); 143 | this.checkJSDoc(node); 144 | }; 145 | ValidJsdocWalker.prototype.visitClassDeclaration = function (node) { 146 | this.startFunction(node); 147 | _super.prototype.visitClassDeclaration.call(this, node); 148 | this.checkJSDoc(node); 149 | }; 150 | ValidJsdocWalker.prototype.visitMethodDeclaration = function (node) { 151 | this.startFunction(node); 152 | _super.prototype.visitMethodDeclaration.call(this, node); 153 | this.checkJSDoc(node); 154 | }; 155 | ValidJsdocWalker.prototype.visitConstructorDeclaration = function (node) { 156 | this.startFunction(node); 157 | _super.prototype.visitConstructorDeclaration.call(this, node); 158 | this.checkJSDoc(node); 159 | }; 160 | ValidJsdocWalker.prototype.visitReturnStatement = function (node) { 161 | this.addReturn(node); 162 | _super.prototype.visitReturnStatement.call(this, node); 163 | }; 164 | ValidJsdocWalker.prototype.startFunction = function (node) { 165 | var returnPresent = false; 166 | var isVoidOrNever = false; 167 | var returnType; 168 | if (node.kind === ts.SyntaxKind.ArrowFunction && node.body.kind !== ts.SyntaxKind.Block) 169 | returnPresent = true; 170 | if (this.isTypeClass(node)) 171 | returnPresent = true; 172 | returnType = node.type; 173 | if (returnType !== undefined) { 174 | switch (returnType.kind) { 175 | case ts.SyntaxKind.VoidKeyword: 176 | case ts.SyntaxKind.NeverKeyword: 177 | isVoidOrNever = true; 178 | break; 179 | } 180 | } 181 | this.fns.push({ node: node, returnPresent: returnPresent, isVoidOrNever: isVoidOrNever }); 182 | }; 183 | ValidJsdocWalker.prototype.addReturn = function (node) { 184 | var parent = node; 185 | var nodes = this.fns.map(function (fn) { return fn.node; }); 186 | while (parent && nodes.indexOf(parent) === -1) 187 | parent = parent.parent; 188 | if (parent && node.expression) { 189 | this.fns[nodes.indexOf(parent)].returnPresent = true; 190 | } 191 | }; 192 | ValidJsdocWalker.prototype.isTypeClass = function (node) { 193 | return node.kind === ts.SyntaxKind.ClassExpression || node.kind === ts.SyntaxKind.ClassDeclaration; 194 | }; 195 | ValidJsdocWalker.prototype.isValidReturnType = function (tag) { 196 | return tag.type && (tag.type.name === 'void' || tag.type.type === 'UndefinedLiteral'); 197 | }; 198 | ValidJsdocWalker.prototype.getJSDocComment = function (node) { 199 | var ALLOWED_PARENTS = [ 200 | ts.SyntaxKind.BinaryExpression, 201 | ts.SyntaxKind.VariableDeclaration, 202 | ts.SyntaxKind.VariableDeclarationList, 203 | ts.SyntaxKind.VariableStatement 204 | ]; 205 | if (!/^\/\*\*/.test(node.getFullText().trim())) { 206 | if (node.parent && ALLOWED_PARENTS.indexOf(node.parent.kind) !== -1) { 207 | return this.getJSDocComment(node.parent); 208 | } 209 | return {}; 210 | } 211 | var comments = node.getFullText(); 212 | var offset = comments.indexOf('/**'); 213 | comments = comments.substring(offset); 214 | comments = comments.substring(0, comments.indexOf('*/') + 2); 215 | var start = node.pos + offset; 216 | var width = comments.length; 217 | if (!/^\/\*\*/.test(comments) || !/\*\/$/.test(comments)) { 218 | return {}; 219 | } 220 | return { comments: comments, start: start, width: width }; 221 | }; 222 | ValidJsdocWalker.prototype.checkJSDoc = function (node) { 223 | var _this = this; 224 | var _a = this.getJSDocComment(node), comments = _a.comments, start = _a.start, width = _a.width; 225 | if (!comments || start === undefined || width === undefined) 226 | return; 227 | var jsdoc; 228 | try { 229 | jsdoc = doctrine.parse(comments, { 230 | strict: true, 231 | unwrap: true, 232 | sloppy: true 233 | }); 234 | } 235 | catch (e) { 236 | if (/braces/i.test(e.message)) { 237 | this.addFailure(this.createFailure(start, width, Rule.FAILURE_STRING.missingBrace)); 238 | } 239 | else { 240 | this.addFailure(this.createFailure(start, width, Rule.FAILURE_STRING.syntaxError)); 241 | } 242 | return; 243 | } 244 | var fn = this.fns.filter(function (f) { return node === f.node; })[0]; 245 | var params = {}; 246 | var hasReturns = false; 247 | var hasConstructor = false; 248 | var isOverride = false; 249 | var isAbstract = false; 250 | for (var _i = 0, _b = jsdoc.tags; _i < _b.length; _i++) { 251 | var tag = _b[_i]; 252 | switch (tag.title) { 253 | case 'param': 254 | case 'arg': 255 | case 'argument': 256 | if (!tag.type && OPTIONS.requireParamType) { 257 | this.addFailure(this.createFailure(start, width, Rule.FAILURE_STRING.missingParameterType(tag.name))); 258 | } 259 | if (!tag.description && OPTIONS.requireParamDescription) { 260 | this.addFailure(this.createFailure(start, width, Rule.FAILURE_STRING.missingParameterDescription(tag.name))); 261 | } 262 | if (params[tag.name]) { 263 | this.addFailure(this.createFailure(start, width, Rule.FAILURE_STRING.duplicateParameter(tag.name))); 264 | } 265 | else if (tag.name.indexOf('.') === -1) { 266 | params[tag.name] = true; 267 | } 268 | break; 269 | case 'return': 270 | case 'returns': 271 | hasReturns = true; 272 | isAbstract = Lint.hasModifier(fn.node.modifiers, ts.SyntaxKind.AbstractKeyword); 273 | if (!isAbstract && !OPTIONS.requireReturn && !fn.returnPresent && tag.type && tag.type.name !== 'void' && tag.type.name !== 'undefined') { 274 | this.addFailure(this.createFailure(start, width, Rule.FAILURE_STRING.unexpectedTag(tag.title))); 275 | } 276 | else { 277 | if (!tag.type && OPTIONS.requireReturnType) { 278 | this.addFailure(this.createFailure(start, width, Rule.FAILURE_STRING.missingReturnType)); 279 | } 280 | if (!this.isValidReturnType(tag) && !tag.description && OPTIONS.requireReturnDescription) { 281 | this.addFailure(this.createFailure(start, width, Rule.FAILURE_STRING.missingReturnDescription)); 282 | } 283 | } 284 | break; 285 | case 'constructor': 286 | case 'class': 287 | hasConstructor = true; 288 | break; 289 | case 'override': 290 | case 'inheritdoc': 291 | case 'inheritDoc': 292 | isOverride = true; 293 | break; 294 | } 295 | var title = OPTIONS.prefer[tag.title]; 296 | if (OPTIONS.prefer.hasOwnProperty(tag.title) && tag.title !== title) { 297 | this.addFailure(this.createFailure(start, width, Rule.FAILURE_STRING.prefer(title))); 298 | } 299 | } 300 | if (!isOverride && !hasReturns && !hasConstructor && node.parent && node.parent.kind !== ts.SyntaxKind.GetKeyword && !this.isTypeClass(node)) { 301 | if (OPTIONS.requireReturn && (fn.returnPresent && !fn.isVoidOrNever)) { 302 | this.addFailure(this.createFailure(start, width, Rule.FAILURE_STRING.missingReturn(OPTIONS.prefer['returns']))); 303 | } 304 | } 305 | var jsdocParams = Object.keys(params); 306 | var parameters = node.parameters; 307 | if (parameters) { 308 | parameters.forEach(function (param, i) { 309 | if (param.name.kind === ts.SyntaxKind.Identifier) { 310 | var name = param.name.text; 311 | if (jsdocParams[i] && name !== jsdocParams[i]) { 312 | _this.addFailure(_this.createFailure(start, width, Rule.FAILURE_STRING.wrongParam(name, jsdocParams[i]))); 313 | } 314 | else if (!params[name] && !isOverride) { 315 | _this.addFailure(_this.createFailure(start, width, Rule.FAILURE_STRING.missingParam(name))); 316 | } 317 | } 318 | }); 319 | } 320 | if (OPTIONS.matchDescription) { 321 | try { 322 | var regex = new RegExp(OPTIONS.matchDescription); 323 | if (!regex.test(jsdoc.description)) { 324 | this.addFailure(this.createFailure(start, width, Rule.FAILURE_STRING.wrongDescription)); 325 | } 326 | } 327 | catch (e) { 328 | this.addFailure(this.createFailure(start, width, e.message)); 329 | } 330 | } 331 | }; 332 | return ValidJsdocWalker; 333 | }(Lint.RuleWalker)); 334 | var templateObject_1, templateObject_2, templateObject_3, templateObject_4; 335 | 336 | //# sourceMappingURL=data:application/json;charset=utf8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInJ1bGVzL3ZhbGlkSnNkb2NSdWxlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7OztBQUFBLCtCQUFpQztBQUNqQyw2QkFBK0I7QUFDL0IsbUNBQXFDO0FBRXJDLElBQU0sU0FBUyxHQUFHLGFBQWEsQ0FBQztBQUNoQyxJQUFJLE9BQVksQ0FBQztBQUVqQjtJQUEwQixnQ0FBdUI7SUFBakQ7O0lBMElBLENBQUM7SUE1QlEsb0JBQUssR0FBWixVQUFhLFVBQXlCO1FBQ3BDLElBQUksSUFBSSxHQUFHLElBQUksQ0FBQyxVQUFVLEVBQUUsQ0FBQyxhQUFhLENBQUM7UUFDM0MsT0FBTyxHQUFHO1lBQ1IsTUFBTSxFQUFFLEVBQUU7WUFDVixhQUFhLEVBQUUsSUFBSTtZQUNuQixnQkFBZ0IsRUFBRSxJQUFJO1lBQ3RCLGlCQUFpQixFQUFFLElBQUk7WUFDdkIsdUJBQXVCLEVBQUUsSUFBSTtZQUM3Qix3QkFBd0IsRUFBRSxJQUFJO1lBQzlCLGdCQUFnQixFQUFFLEVBQUU7U0FDckIsQ0FBQztRQUVGLElBQUksSUFBSSxJQUFJLElBQUksQ0FBQyxNQUFNLEdBQUcsQ0FBQyxFQUFFO1lBQzNCLElBQUksSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDLE1BQU0sRUFBRTtnQkFDbEIsT0FBTyxDQUFDLE1BQU0sR0FBRyxJQUFJLENBQUMsQ0FBQyxDQUFDLENBQUMsTUFBTSxDQUFDO2FBQ2pDO1lBRUQsT0FBTyxDQUFDLGFBQWEsR0FBRyxJQUFJLENBQUMsQ0FBQyxDQUFDLENBQUMsYUFBYSxLQUFLLEtBQUssQ0FBQztZQUN4RCxPQUFPLENBQUMsZ0JBQWdCLEdBQUcsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDLGdCQUFnQixLQUFLLEtBQUssQ0FBQztZQUM5RCxPQUFPLENBQUMsaUJBQWlCLEdBQUcsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDLGlCQUFpQixLQUFLLEtBQUssQ0FBQztZQUNoRSxPQUFPLENBQUMsdUJBQXVCLEdBQUcsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDLHVCQUF1QixLQUFLLEtBQUssQ0FBQztZQUM1RSxPQUFPLENBQUMsd0JBQXdCLEdBQUcsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDLHdCQUF3QixLQUFLLEtBQUssQ0FBQztZQUM5RSxPQUFPLENBQUMsZ0JBQWdCLEdBQUcsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDLGdCQUFnQixDQUFDO1NBQ3JEO1FBRUQsSUFBTSxNQUFNLEdBQUcsSUFBSSxnQkFBZ0IsQ0FBQyxVQUFVLEVBQUUsSUFBSSxDQUFDLFVBQVUsRUFBRSxDQUFDLENBQUM7UUFDbkUsT0FBTyxJQUFJLENBQUMsZUFBZSxDQUFDLE1BQU0sQ0FBQyxDQUFDO0lBQ3RDLENBQUM7SUF4SWEsbUJBQWMsR0FBRztRQUM3QixZQUFZLEVBQUUsMEJBQTBCO1FBQ3hDLFdBQVcsRUFBRSxvQkFBb0I7UUFDakMsb0JBQW9CLEVBQUUsVUFBQyxJQUFZLElBQUssT0FBQSx1Q0FBcUMsSUFBSSxNQUFHLEVBQTVDLENBQTRDO1FBQ3BGLDJCQUEyQixFQUFFLFVBQUMsSUFBWSxJQUFLLE9BQUEsOENBQTRDLElBQUksTUFBRyxFQUFuRCxDQUFtRDtRQUNsRyxrQkFBa0IsRUFBRSxVQUFDLElBQVksSUFBSyxPQUFBLGdDQUE4QixJQUFJLE1BQUcsRUFBckMsQ0FBcUM7UUFDM0UsYUFBYSxFQUFFLFVBQUMsS0FBYSxJQUFLLE9BQUEsaUJBQWUsS0FBSywyQ0FBd0MsRUFBNUQsQ0FBNEQ7UUFDOUYsaUJBQWlCLEVBQUUsMkJBQTJCO1FBQzlDLHdCQUF3QixFQUFFLGtDQUFrQztRQUM1RCxNQUFNLEVBQUUsVUFBQyxJQUFZLElBQUssT0FBQSxVQUFRLElBQUksYUFBVSxFQUF0QixDQUFzQjtRQUNoRCxhQUFhLEVBQUUsVUFBQyxLQUFhLElBQUssT0FBQSxxQkFBa0IsS0FBSyxJQUFJLFNBQVMsbUJBQWUsRUFBbkQsQ0FBbUQ7UUFDckYsVUFBVSxFQUFFLFVBQUMsUUFBZ0IsRUFBRSxNQUFjLElBQUssT0FBQSx5QkFBdUIsUUFBUSxxQkFBZ0IsTUFBTSxNQUFHLEVBQXhELENBQXdEO1FBQzFHLFlBQVksRUFBRSxVQUFDLElBQVksSUFBSyxPQUFBLGtDQUFnQyxJQUFJLE1BQUcsRUFBdkMsQ0FBdUM7UUFDdkUsZ0JBQWdCLEVBQUUsc0RBQXNEO1FBQ3hFLHVCQUF1QixFQUFFLFVBQUMsS0FBYSxJQUFLLE9BQUEsOERBQTRELEtBQU8sRUFBbkUsQ0FBbUU7S0FDaEgsQ0FBQztJQUVZLGFBQVEsR0FBdUI7UUFDM0MsUUFBUSxFQUFFLFNBQVM7UUFDbkIsTUFBTSxFQUFFLEtBQUs7UUFDYixXQUFXLEVBQUUsOEJBQThCO1FBQzNDLFNBQVMsRUFBRSxJQUFJLENBQUMsS0FBSyxDQUFDLE1BQU0seWhCQUFBLDhjQVF6QixJQUFBO1FBQ0gsa0JBQWtCLEVBQUUsSUFBSSxDQUFDLEtBQUssQ0FBQyxNQUFNLHNwREFBQSwrcERBc0JsQyxJQUFBO1FBQ0gsT0FBTyxFQUFFO1lBQ1AsSUFBSSxFQUFFLFFBQVE7WUFDZCxVQUFVLEVBQUU7Z0JBQ1YsTUFBTSxFQUFFO29CQUNOLElBQUksRUFBRSxRQUFRO29CQUNkLG9CQUFvQixFQUFFO3dCQUNwQixJQUFJLEVBQUUsUUFBUTtxQkFDZjtpQkFDRjtnQkFDRCxVQUFVLEVBQUU7b0JBQ1YsSUFBSSxFQUFFLFFBQVE7b0JBQ2Qsb0JBQW9CLEVBQUU7d0JBQ3BCLElBQUksRUFBRSxRQUFRO3FCQUNmO2lCQUNGO2dCQUNELGFBQWEsRUFBRTtvQkFDYixJQUFJLEVBQUUsU0FBUztpQkFDaEI7Z0JBQ0QsdUJBQXVCLEVBQUU7b0JBQ3ZCLElBQUksRUFBRSxTQUFTO2lCQUNoQjtnQkFDRCx3QkFBd0IsRUFBRTtvQkFDeEIsSUFBSSxFQUFFLFNBQVM7aUJBQ2hCO2dCQUNELGdCQUFnQixFQUFFO29CQUNoQixJQUFJLEVBQUUsUUFBUTtpQkFDZjtnQkFDRCxnQkFBZ0IsRUFBRTtvQkFDaEIsSUFBSSxFQUFFLFNBQVM7aUJBQ2hCO2dCQUNELGlCQUFpQixFQUFFO29CQUNqQixJQUFJLEVBQUUsU0FBUztpQkFDaEI7YUFDRjtZQUNELG9CQUFvQixFQUFFLEtBQUs7U0FDNUI7UUFDRCxjQUFjLEVBQUU7WUFDZCxJQUFJLENBQUMsS0FBSyxDQUFDLE1BQU0saUhBQUEsY0FDWixFQUFTLHNCQUNYLEtBREUsU0FBUztZQUVkLElBQUksQ0FBQyxLQUFLLENBQUMsTUFBTSw4WUFBQSxjQUNaLEVBQVMsdVRBU1gsS0FURSxTQUFTO1NBVWY7UUFDRCxjQUFjLEVBQUUsS0FBSztRQUNyQixJQUFJLEVBQUUsaUJBQWlCO0tBQ3hCLENBQUM7SUE4QkosV0FBQztDQTFJRCxBQTBJQyxDQTFJeUIsSUFBSSxDQUFDLEtBQUssQ0FBQyxZQUFZLEdBMEloRDtBQTFJWSxvQkFBSTtBQXdKakI7SUFBK0IsNENBQWU7SUFBOUM7UUFBQSxxRUF3UUM7UUF2UVMsU0FBRyxHQUEwQixFQUFFLENBQUM7O0lBdVExQyxDQUFDO0lBclFXLDBDQUFlLEdBQXpCLFVBQTBCLElBQW1CO1FBQzNDLGlCQUFNLGVBQWUsWUFBQyxJQUFJLENBQUMsQ0FBQztJQUM5QixDQUFDO0lBRVMsb0NBQVMsR0FBbkIsVUFBb0IsSUFBYTtRQUMvQixJQUFJLElBQUksQ0FBQyxJQUFJLEtBQUssRUFBRSxDQUFDLFVBQVUsQ0FBQyxlQUFlLEVBQUU7WUFDL0MsSUFBSSxDQUFDLG9CQUFvQixDQUFDLElBQTBCLENBQUMsQ0FBQztTQUN2RDthQUNJO1lBQ0gsaUJBQU0sU0FBUyxZQUFDLElBQUksQ0FBQyxDQUFDO1NBQ3ZCO0lBQ0gsQ0FBQztJQUVTLDZDQUFrQixHQUE1QixVQUE2QixJQUFzQjtRQUNqRCxJQUFJLENBQUMsYUFBYSxDQUFDLElBQUksQ0FBQyxDQUFDO1FBQ3pCLGlCQUFNLGtCQUFrQixZQUFDLElBQUksQ0FBQyxDQUFDO1FBQy9CLElBQUksQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLENBQUM7SUFDeEIsQ0FBQztJQUVTLGtEQUF1QixHQUFqQyxVQUFrQyxJQUEyQjtRQUMzRCxJQUFJLENBQUMsYUFBYSxDQUFDLElBQUksQ0FBQyxDQUFDO1FBQ3pCLGlCQUFNLHVCQUF1QixZQUFDLElBQUksQ0FBQyxDQUFDO1FBQ3BDLElBQUksQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLENBQUM7SUFDeEIsQ0FBQztJQUVTLG1EQUF3QixHQUFsQyxVQUFtQyxJQUE0QjtRQUM3RCxJQUFJLENBQUMsYUFBYSxDQUFDLElBQUksQ0FBQyxDQUFDO1FBQ3pCLGlCQUFNLHdCQUF3QixZQUFDLElBQUksQ0FBQyxDQUFDO1FBQ3JDLElBQUksQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLENBQUM7SUFDeEIsQ0FBQztJQUVTLCtDQUFvQixHQUE5QixVQUErQixJQUF3QjtRQUNyRCxJQUFJLENBQUMsYUFBYSxDQUFDLElBQUksQ0FBQyxDQUFDO1FBQ3pCLGlCQUFNLG9CQUFvQixZQUFDLElBQUksQ0FBQyxDQUFDO1FBQ2pDLElBQUksQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLENBQUM7SUFDeEIsQ0FBQztJQUVTLGdEQUFxQixHQUEvQixVQUFnQyxJQUF5QjtRQUN2RCxJQUFJLENBQUMsYUFBYSxDQUFDLElBQUksQ0FBQyxDQUFDO1FBQ3pCLGlCQUFNLHFCQUFxQixZQUFDLElBQUksQ0FBQyxDQUFDO1FBQ2xDLElBQUksQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLENBQUM7SUFDeEIsQ0FBQztJQUVTLGlEQUFzQixHQUFoQyxVQUFpQyxJQUEwQjtRQUN6RCxJQUFJLENBQUMsYUFBYSxDQUFDLElBQUksQ0FBQyxDQUFDO1FBQ3pCLGlCQUFNLHNCQUFzQixZQUFDLElBQUksQ0FBQyxDQUFDO1FBQ25DLElBQUksQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLENBQUM7SUFDeEIsQ0FBQztJQUVTLHNEQUEyQixHQUFyQyxVQUFzQyxJQUErQjtRQUNuRSxJQUFJLENBQUMsYUFBYSxDQUFDLElBQUksQ0FBQyxDQUFDO1FBQ3pCLGlCQUFNLDJCQUEyQixZQUFDLElBQUksQ0FBQyxDQUFDO1FBQ3hDLElBQUksQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLENBQUM7SUFDeEIsQ0FBQztJQUVTLCtDQUFvQixHQUE5QixVQUErQixJQUF3QjtRQUNyRCxJQUFJLENBQUMsU0FBUyxDQUFDLElBQUksQ0FBQyxDQUFDO1FBQ3JCLGlCQUFNLG9CQUFvQixZQUFDLElBQUksQ0FBQyxDQUFDO0lBQ25DLENBQUM7SUFFTyx3Q0FBYSxHQUFyQixVQUFzQixJQUFhO1FBQ2pDLElBQUksYUFBYSxHQUFHLEtBQUssQ0FBQztRQUMxQixJQUFJLGFBQWEsR0FBRyxLQUFLLENBQUM7UUFDMUIsSUFBSSxVQUFtQyxDQUFDO1FBRXhDLElBQUksSUFBSSxDQUFDLElBQUksS0FBSyxFQUFFLENBQUMsVUFBVSxDQUFDLGFBQWEsSUFBSyxJQUF5QixDQUFDLElBQUksQ0FBQyxJQUFJLEtBQUssRUFBRSxDQUFDLFVBQVUsQ0FBQyxLQUFLO1lBQzNHLGFBQWEsR0FBRyxJQUFJLENBQUM7UUFFdkIsSUFBSSxJQUFJLENBQUMsV0FBVyxDQUFDLElBQUksQ0FBQztZQUN4QixhQUFhLEdBQUcsSUFBSSxDQUFDO1FBRXZCLFVBQVUsR0FBSSxJQUFnQyxDQUFDLElBQUksQ0FBQztRQUVwRCxJQUFJLFVBQVUsS0FBSyxTQUFTLEVBQUU7WUFDNUIsUUFBUSxVQUFVLENBQUMsSUFBSSxFQUFFO2dCQUN2QixLQUFLLEVBQUUsQ0FBQyxVQUFVLENBQUMsV0FBVyxDQUFDO2dCQUMvQixLQUFLLEVBQUUsQ0FBQyxVQUFVLENBQUMsWUFBWTtvQkFDN0IsYUFBYSxHQUFHLElBQUksQ0FBQztvQkFDckIsTUFBTTthQUNUO1NBQ0Y7UUFFRCxJQUFJLENBQUMsR0FBRyxDQUFDLElBQUksQ0FBQyxFQUFFLElBQUksTUFBQSxFQUFFLGFBQWEsZUFBQSxFQUFFLGFBQWEsZUFBQSxFQUFFLENBQUMsQ0FBQztJQUN4RCxDQUFDO0lBRU8sb0NBQVMsR0FBakIsVUFBa0IsSUFBd0I7UUFDeEMsSUFBSSxNQUFNLEdBQXdCLElBQUksQ0FBQztRQUN2QyxJQUFJLEtBQUssR0FBRyxJQUFJLENBQUMsR0FBRyxDQUFDLEdBQUcsQ0FBQyxVQUFBLEVBQUUsSUFBSSxPQUFBLEVBQUUsQ0FBQyxJQUFJLEVBQVAsQ0FBTyxDQUFDLENBQUM7UUFFeEMsT0FBTyxNQUFNLElBQUksS0FBSyxDQUFDLE9BQU8sQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUM7WUFDM0MsTUFBTSxHQUFHLE1BQU0sQ0FBQyxNQUFNLENBQUM7UUFFekIsSUFBSSxNQUFNLElBQUksSUFBSSxDQUFDLFVBQVUsRUFBRTtZQUM3QixJQUFJLENBQUMsR0FBRyxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsTUFBTSxDQUFDLENBQUMsQ0FBQyxhQUFhLEdBQUcsSUFBSSxDQUFDO1NBQ3REO0lBQ0gsQ0FBQztJQUVPLHNDQUFXLEdBQW5CLFVBQW9CLElBQWE7UUFDL0IsT0FBTyxJQUFJLENBQUMsSUFBSSxLQUFLLEVBQUUsQ0FBQyxVQUFVLENBQUMsZUFBZSxJQUFJLElBQUksQ0FBQyxJQUFJLEtBQUssRUFBRSxDQUFDLFVBQVUsQ0FBQyxnQkFBZ0IsQ0FBQztJQUNyRyxDQUFDO0lBRU8sNENBQWlCLEdBQXpCLFVBQTBCLEdBQXVCO1FBQy9DLE9BQU8sR0FBRyxDQUFDLElBQUksSUFBSSxDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUMsSUFBSSxLQUFLLE1BQU0sSUFBSSxHQUFHLENBQUMsSUFBSSxDQUFDLElBQUksS0FBSyxrQkFBa0IsQ0FBQyxDQUFDO0lBQ3hGLENBQUM7SUFFTywwQ0FBZSxHQUF2QixVQUF3QixJQUFhO1FBQ25DLElBQU0sZUFBZSxHQUFHO1lBQ3RCLEVBQUUsQ0FBQyxVQUFVLENBQUMsZ0JBQWdCO1lBQzlCLEVBQUUsQ0FBQyxVQUFVLENBQUMsbUJBQW1CO1lBQ2pDLEVBQUUsQ0FBQyxVQUFVLENBQUMsdUJBQXVCO1lBQ3JDLEVBQUUsQ0FBQyxVQUFVLENBQUMsaUJBQWlCO1NBQ2hDLENBQUM7UUFFRixJQUFJLENBQUMsU0FBUyxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsV0FBVyxFQUFFLENBQUMsSUFBSSxFQUFFLENBQUMsRUFBRTtZQUM5QyxJQUFJLElBQUksQ0FBQyxNQUFNLElBQUksZUFBZSxDQUFDLE9BQU8sQ0FBQyxJQUFJLENBQUMsTUFBTSxDQUFDLElBQUksQ0FBQyxLQUFLLENBQUMsQ0FBQyxFQUFFO2dCQUNuRSxPQUFPLElBQUksQ0FBQyxlQUFlLENBQUMsSUFBSSxDQUFDLE1BQU0sQ0FBQyxDQUFDO2FBQzFDO1lBQ0QsT0FBTyxFQUFFLENBQUM7U0FDWDtRQUVELElBQUksUUFBUSxHQUFHLElBQUksQ0FBQyxXQUFXLEVBQUUsQ0FBQztRQUNsQyxJQUFJLE1BQU0sR0FBRyxRQUFRLENBQUMsT0FBTyxDQUFDLEtBQUssQ0FBQyxDQUFDO1FBQ3JDLFFBQVEsR0FBRyxRQUFRLENBQUMsU0FBUyxDQUFDLE1BQU0sQ0FBQyxDQUFDO1FBQ3RDLFFBQVEsR0FBRyxRQUFRLENBQUMsU0FBUyxDQUFDLENBQUMsRUFBRSxRQUFRLENBQUMsT0FBTyxDQUFDLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUFDO1FBRTdELElBQUksS0FBSyxHQUFHLElBQUksQ0FBQyxHQUFHLEdBQUcsTUFBTSxDQUFDO1FBQzlCLElBQUksS0FBSyxHQUFHLFFBQVEsQ0FBQyxNQUFNLENBQUM7UUFFNUIsSUFBSSxDQUFDLFNBQVMsQ0FBQyxJQUFJLENBQUMsUUFBUSxDQUFDLElBQUksQ0FBQyxPQUFPLENBQUMsSUFBSSxDQUFDLFFBQVEsQ0FBQyxFQUFFO1lBQ3hELE9BQU8sRUFBRSxDQUFDO1NBQ1g7UUFFRCxPQUFPLEVBQUUsUUFBUSxVQUFBLEVBQUUsS0FBSyxPQUFBLEVBQUUsS0FBSyxPQUFBLEVBQUUsQ0FBQztJQUNwQyxDQUFDO0lBRU8scUNBQVUsR0FBbEIsVUFBbUIsSUFBYTtRQUFoQyxpQkE2SEM7UUE1SE8sSUFBQSwrQkFBdUQsRUFBckQsc0JBQVEsRUFBRSxnQkFBSyxFQUFFLGdCQUFLLENBQWdDO1FBRTlELElBQUksQ0FBQyxRQUFRLElBQUksS0FBSyxLQUFLLFNBQVMsSUFBSSxLQUFLLEtBQUssU0FBUztZQUN6RCxPQUFPO1FBRVQsSUFBSSxLQUE2QixDQUFDO1FBRWxDLElBQUk7WUFDRixLQUFLLEdBQUcsUUFBUSxDQUFDLEtBQUssQ0FBQyxRQUFRLEVBQUU7Z0JBQy9CLE1BQU0sRUFBRSxJQUFJO2dCQUNaLE1BQU0sRUFBRSxJQUFJO2dCQUNaLE1BQU0sRUFBRSxJQUFJO2FBQ2IsQ0FBQyxDQUFDO1NBQ0o7UUFDRCxPQUFPLENBQUMsRUFBRTtZQUNSLElBQUksU0FBUyxDQUFDLElBQUksQ0FBQyxDQUFDLENBQUMsT0FBTyxDQUFDLEVBQUU7Z0JBQzdCLElBQUksQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLGFBQWEsQ0FBQyxLQUFLLEVBQUUsS0FBSyxFQUFFLElBQUksQ0FBQyxjQUFjLENBQUMsWUFBWSxDQUFDLENBQUMsQ0FBQzthQUNyRjtpQkFDSTtnQkFDSCxJQUFJLENBQUMsVUFBVSxDQUFDLElBQUksQ0FBQyxhQUFhLENBQUMsS0FBSyxFQUFFLEtBQUssRUFBRSxJQUFJLENBQUMsY0FBYyxDQUFDLFdBQVcsQ0FBQyxDQUFDLENBQUM7YUFDcEY7WUFDRCxPQUFPO1NBQ1I7UUFFRCxJQUFJLEVBQUUsR0FBRyxJQUFJLENBQUMsR0FBRyxDQUFDLE1BQU0sQ0FBQyxVQUFBLENBQUMsSUFBSSxPQUFBLElBQUksS0FBSyxDQUFDLENBQUMsSUFBSSxFQUFmLENBQWUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDO1FBQ2xELElBQUksTUFBTSxHQUFHLEVBQUUsQ0FBQztRQUNoQixJQUFJLFVBQVUsR0FBRyxLQUFLLENBQUM7UUFDdkIsSUFBSSxjQUFjLEdBQUcsS0FBSyxDQUFDO1FBQzNCLElBQUksVUFBVSxHQUFHLEtBQUssQ0FBQztRQUN2QixJQUFJLFVBQVUsR0FBRyxLQUFLLENBQUM7UUFFdkIsS0FBZ0IsVUFBVSxFQUFWLEtBQUEsS0FBSyxDQUFDLElBQUksRUFBVixjQUFVLEVBQVYsSUFBVSxFQUFFO1lBQXZCLElBQUksR0FBRyxTQUFBO1lBQ1YsUUFBUSxHQUFHLENBQUMsS0FBSyxFQUFFO2dCQUNqQixLQUFLLE9BQU8sQ0FBQztnQkFDYixLQUFLLEtBQUssQ0FBQztnQkFDWCxLQUFLLFVBQVU7b0JBQ2IsSUFBSSxDQUFDLEdBQUcsQ0FBQyxJQUFJLElBQUksT0FBTyxDQUFDLGdCQUFnQixFQUFFO3dCQUN6QyxJQUFJLENBQUMsVUFBVSxDQUFDLElBQUksQ0FBQyxhQUFhLENBQUMsS0FBSyxFQUFFLEtBQUssRUFBRSxJQUFJLENBQUMsY0FBYyxDQUFDLG9CQUFvQixDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDLENBQUM7cUJBQ3ZHO29CQUVELElBQUksQ0FBQyxHQUFHLENBQUMsV0FBVyxJQUFJLE9BQU8sQ0FBQyx1QkFBdUIsRUFBRTt3QkFDdkQsSUFBSSxDQUFDLFVBQVUsQ0FBQyxJQUFJLENBQUMsYUFBYSxDQUFDLEtBQUssRUFBRSxLQUFLLEVBQUUsSUFBSSxDQUFDLGNBQWMsQ0FBQywyQkFBMkIsQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDO3FCQUM5RztvQkFFRCxJQUFJLE1BQU0sQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLEVBQUU7d0JBQ3BCLElBQUksQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLGFBQWEsQ0FBQyxLQUFLLEVBQUUsS0FBSyxFQUFFLElBQUksQ0FBQyxjQUFjLENBQUMsa0JBQWtCLENBQUMsR0FBRyxDQUFDLElBQUksQ0FBQyxDQUFDLENBQUMsQ0FBQztxQkFDckc7eUJBQ0ksSUFBSSxHQUFHLENBQUMsSUFBSSxDQUFDLE9BQU8sQ0FBQyxHQUFHLENBQUMsS0FBSyxDQUFDLENBQUMsRUFBRTt3QkFDckMsTUFBTSxDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUMsR0FBRyxJQUFJLENBQUM7cUJBQ3pCO29CQUNELE1BQU07Z0JBQ1IsS0FBSyxRQUFRLENBQUM7Z0JBQ2QsS0FBSyxTQUFTO29CQUNaLFVBQVUsR0FBRyxJQUFJLENBQUM7b0JBRWxCLFVBQVUsR0FBRyxJQUFJLENBQUMsV0FBVyxDQUFDLEVBQUUsQ0FBQyxJQUFJLENBQUMsU0FBUyxFQUFFLEVBQUUsQ0FBQyxVQUFVLENBQUMsZUFBZSxDQUFDLENBQUM7b0JBRWhGLElBQUksQ0FBQyxVQUFVLElBQUksQ0FBQyxPQUFPLENBQUMsYUFBYSxJQUFJLENBQUMsRUFBRSxDQUFDLGFBQWEsSUFBSSxHQUFHLENBQUMsSUFBSSxJQUFJLEdBQUcsQ0FBQyxJQUFJLENBQUMsSUFBSSxLQUFLLE1BQU0sSUFBSSxHQUFHLENBQUMsSUFBSSxDQUFDLElBQUksS0FBSyxXQUFXLEVBQUU7d0JBQ3ZJLElBQUksQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDLGFBQWEsQ0FBQyxLQUFLLEVBQUUsS0FBSyxFQUFFLElBQUksQ0FBQyxjQUFjLENBQUMsYUFBYSxDQUFDLEdBQUcsQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLENBQUM7cUJBQ2pHO3lCQUNJO3dCQUNILElBQUksQ0FBQyxHQUFHLENBQUMsSUFBSSxJQUFJLE9BQU8sQ0FBQyxpQkFBaUIsRUFBRTs0QkFDMUMsSUFBSSxDQUFDLFVBQVUsQ0FBQyxJQUFJLENBQUMsYUFBYSxDQUFDLEtBQUssRUFBRSxLQUFLLEVBQUUsSUFBSSxDQUFDLGNBQWMsQ0FBQyxpQkFBaUIsQ0FBQyxDQUFDLENBQUM7eUJBQzFGO3dCQUVELElBQUksQ0FBQyxJQUFJLENBQUMsaUJBQWlCLENBQUMsR0FBRyxDQUFDLElBQUksQ0FBQyxHQUFHLENBQUMsV0FBVyxJQUFJLE9BQU8sQ0FBQyx3QkFBd0IsRUFBRTs0QkFDeEYsSUFBSSxDQUFDLFVBQVUsQ0FBQyxJQUFJLENBQUMsYUFBYSxDQUFDLEtBQUssRUFBRSxLQUFLLEVBQUUsSUFBSSxDQUFDLGNBQWMsQ0FBQyx3QkFBd0IsQ0FBQyxDQUFDLENBQUM7eUJBQ2pHO3FCQUNGO29CQUNELE1BQU07Z0JBQ1IsS0FBSyxhQUFhLENBQUM7Z0JBQ25CLEtBQUssT0FBTztvQkFDVixjQUFjLEdBQUcsSUFBSSxDQUFDO29CQUN0QixNQUFNO2dCQUNSLEtBQUssVUFBVSxDQUFDO2dCQUNoQixLQUFLLFlBQVksQ0FBQztnQkFDbEIsS0FBSyxZQUFZO29CQUNmLFVBQVUsR0FBRyxJQUFJLENBQUM7b0JBQ2xCLE1BQU07YUFDVDtZQUdELElBQUksS0FBSyxHQUFHLE9BQU8sQ0FBQyxNQUFNLENBQUMsR0FBRyxDQUFDLEtBQUssQ0FBQyxDQUFDO1lBQ3RDLElBQUksT0FBTyxDQUFDLE1BQU0sQ0FBQyxjQUFjLENBQUMsR0FBRyxDQUFDLEtBQUssQ0FBQyxJQUFJLEdBQUcsQ0FBQyxLQUFLLEtBQUssS0FBSyxFQUFFO2dCQUNuRSxJQUFJLENBQUMsVUFBVSxDQUFDLElBQUksQ0FBQyxhQUFhLENBQUMsS0FBSyxFQUFFLEtBQUssRUFBRSxJQUFJLENBQUMsY0FBYyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLENBQUM7YUFDdEY7U0FDRjtRQUdELElBQUksQ0FBQyxVQUFVLElBQUksQ0FBQyxVQUFVLElBQUksQ0FBQyxjQUFjLElBQUksSUFBSSxDQUFDLE1BQU0sSUFBSSxJQUFJLENBQUMsTUFBTSxDQUFDLElBQUksS0FBSyxFQUFFLENBQUMsVUFBVSxDQUFDLFVBQVUsSUFBSSxDQUFDLElBQUksQ0FBQyxXQUFXLENBQUMsSUFBSSxDQUFDLEVBQUU7WUFDNUksSUFBSSxPQUFPLENBQUMsYUFBYSxJQUFJLENBQUMsRUFBRSxDQUFDLGFBQWEsSUFBSSxDQUFDLEVBQUUsQ0FBQyxhQUFhLENBQUMsRUFBRTtnQkFDcEUsSUFBSSxDQUFDLFVBQVUsQ0FBQyxJQUFJLENBQUMsYUFBYSxDQUFDLEtBQUssRUFBRSxLQUFLLEVBQUUsSUFBSSxDQUFDLGNBQWMsQ0FBQyxhQUFhLENBQUMsT0FBTyxDQUFDLE1BQU0sQ0FBQyxTQUFTLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQzthQUNqSDtTQUNGO1FBR0QsSUFBTSxXQUFXLEdBQUcsTUFBTSxDQUFDLElBQUksQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUN4QyxJQUFNLFVBQVUsR0FBSSxJQUFnQyxDQUFDLFVBQVUsQ0FBQztRQUVoRSxJQUFJLFVBQVUsRUFBRTtZQUNkLFVBQVUsQ0FBQyxPQUFPLENBQUMsVUFBQyxLQUFLLEVBQUUsQ0FBQztnQkFDMUIsSUFBSSxLQUFLLENBQUMsSUFBSSxDQUFDLElBQUksS0FBSyxFQUFFLENBQUMsVUFBVSxDQUFDLFVBQVUsRUFBRTtvQkFDaEQsSUFBSSxJQUFJLEdBQUksS0FBSyxDQUFDLElBQXNCLENBQUMsSUFBSSxDQUFDO29CQUM5QyxJQUFJLFdBQVcsQ0FBQyxDQUFDLENBQUMsSUFBSSxJQUFJLEtBQUssV0FBVyxDQUFDLENBQUMsQ0FBQyxFQUFFO3dCQUM3QyxLQUFJLENBQUMsVUFBVSxDQUFDLEtBQUksQ0FBQyxhQUFhLENBQUMsS0FBSyxFQUFFLEtBQUssRUFBRSxJQUFJLENBQUMsY0FBYyxDQUFDLFVBQVUsQ0FBQyxJQUFJLEVBQUUsV0FBVyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDO3FCQUN6Rzt5QkFDSSxJQUFJLENBQUMsTUFBTSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsVUFBVSxFQUFFO3dCQUNyQyxLQUFJLENBQUMsVUFBVSxDQUFDLEtBQUksQ0FBQyxhQUFhLENBQUMsS0FBSyxFQUFFLEtBQUssRUFBRSxJQUFJLENBQUMsY0FBYyxDQUFDLFlBQVksQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDLENBQUM7cUJBQzNGO2lCQUNGO1lBQ0gsQ0FBQyxDQUFDLENBQUM7U0FDSjtRQUVELElBQUksT0FBTyxDQUFDLGdCQUFnQixFQUFFO1lBQzVCLElBQUk7Z0JBQ0YsSUFBTSxLQUFLLEdBQUcsSUFBSSxNQUFNLENBQUMsT0FBTyxDQUFDLGdCQUFnQixDQUFDLENBQUM7Z0JBQ25ELElBQUksQ0FBQyxLQUFLLENBQUMsSUFBSSxDQUFDLEtBQUssQ0FBQyxXQUFXLENBQUMsRUFBRTtvQkFDbEMsSUFBSSxDQUFDLFVBQVUsQ0FBQyxJQUFJLENBQUMsYUFBYSxDQUFDLEtBQUssRUFBRSxLQUFLLEVBQUUsSUFBSSxDQUFDLGNBQWMsQ0FBQyxnQkFBZ0IsQ0FBQyxDQUFDLENBQUM7aUJBQ3pGO2FBQ0Y7WUFDRCxPQUFPLENBQUMsRUFBRTtnQkFDUixJQUFJLENBQUMsVUFBVSxDQUFDLElBQUksQ0FBQyxhQUFhLENBQUMsS0FBSyxFQUFFLEtBQUssRUFBRSxDQUFDLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQzthQUM5RDtTQUNGO0lBQ0gsQ0FBQztJQUNILHVCQUFDO0FBQUQsQ0F4UUEsQUF3UUMsQ0F4UThCLElBQUksQ0FBQyxVQUFVLEdBd1E3QyIsImZpbGUiOiJydWxlcy92YWxpZEpzZG9jUnVsZS5qcyIsInNvdXJjZVJvb3QiOiIvVXNlcnMvam1sb3Blei90c2xpbnQtZXNsaW50LXJ1bGVzL3NyYyJ9 337 | -------------------------------------------------------------------------------- /src/wss/wss.room.ts: -------------------------------------------------------------------------------- 1 | import config from 'config'; 2 | import io from 'socket.io'; 3 | 4 | import AudioLevelObserver from 'mediasoup/lib/AudioLevelObserver'; 5 | import Router, { RouterOptions } from 'mediasoup/lib/Router'; 6 | import { MediaKind, RtpCapabilities } from 'mediasoup/lib/RtpParameters'; 7 | import { 8 | Consumer, 9 | ConsumerLayers, 10 | ConsumerScore, 11 | DtlsParameters, 12 | Producer, 13 | ProducerScore, 14 | ProducerVideoOrientation, 15 | WebRtcTransport, 16 | } from 'mediasoup/lib/types'; 17 | import Worker from 'mediasoup/lib/Worker'; 18 | 19 | type TPeer = 'producer' | 'consumer'; 20 | 21 | import { IClient, IClientQuery, IMediasoupClient, IMsMessage } from './wss.interfaces'; 22 | 23 | import { LoggerService } from '../logger/logger.service'; 24 | 25 | const mediasoupSettings = config.get('MEDIASOUP_SETTINGS'); 26 | 27 | export class WssRoom { 28 | public readonly clients: Map = new Map(); 29 | 30 | public router: Router; 31 | public audioLevelObserver: AudioLevelObserver; 32 | 33 | constructor( 34 | private worker: Worker, 35 | public workerIndex: number, 36 | public readonly session_id: string, 37 | private readonly logger: LoggerService, 38 | private readonly wssServer: io.Server 39 | ) {} 40 | 41 | private async configureWorker() { 42 | try { 43 | await this.worker 44 | .createRouter({ mediaCodecs: mediasoupSettings.router.mediaCodecs } as RouterOptions) 45 | .then(router => { 46 | this.router = router; 47 | return this.router.createAudioLevelObserver({ maxEntries: 1, threshold: -80, interval: 800 }); 48 | }) 49 | .then(observer => (this.audioLevelObserver = observer)) 50 | .then(() => { 51 | // tslint:disable-next-line: no-any 52 | this.audioLevelObserver.on('volumes', (volumes: Array<{ producer: Producer; volume: number }>) => { 53 | this.wssServer.to(this.session_id).emit('mediaActiveSpeaker', { 54 | user_id: (volumes[0].producer.appData as { user_id: string }).user_id, 55 | volume: volumes[0].volume, 56 | }); 57 | }); 58 | 59 | this.audioLevelObserver.on('silence', () => { 60 | this.wssServer.to(this.session_id).emit('mediaActiveSpeaker', { 61 | user_id: null, 62 | }); 63 | }); 64 | }); 65 | } catch (error) { 66 | this.logger.error(error.message, error.stack, 'WssRoom - configureWorker'); 67 | } 68 | } 69 | 70 | get clientsCount(): number { 71 | return this.clients.size; 72 | } 73 | 74 | get clientsIds(): string[] { 75 | return Array.from(this.clients.keys()); 76 | } 77 | 78 | get audioProducerIds(): string[] { 79 | return Array.from(this.clients.values()) 80 | .filter(c => { 81 | if (c.media && c.media.producerAudio && !c.media.producerAudio.closed) { 82 | return true; 83 | } 84 | 85 | return false; 86 | }) 87 | .map(c => c.id); 88 | } 89 | 90 | get videoProducerIds(): string[] { 91 | return Array.from(this.clients.values()) 92 | .filter(c => { 93 | if (c.media && c.media.producerVideo && !c.media.producerVideo.closed) { 94 | return true; 95 | } 96 | 97 | return false; 98 | }) 99 | .map(c => c.id); 100 | } 101 | 102 | get producerIds(): string[] { 103 | return Array.from(this.clients.values()) 104 | .filter(c => { 105 | if (c.media) { 106 | if (c.media.producerVideo || c.media.producerAudio) { 107 | return true; 108 | } else { 109 | return false; 110 | } 111 | } else { 112 | return false; 113 | } 114 | }) 115 | .map(c => c.id); 116 | } 117 | 118 | get getRouterRtpCapabilities(): RtpCapabilities { 119 | return this.router.rtpCapabilities; 120 | } 121 | 122 | get stats() { 123 | const clientsArray = Array.from(this.clients.values()); 124 | 125 | return { 126 | id: this.session_id, 127 | worker: this.workerIndex, 128 | clients: clientsArray.map(c => ({ 129 | id: c.id, 130 | device: c.device, 131 | produceAudio: c.media.producerAudio ? true : false, 132 | produceVideo: c.media.producerVideo ? true : false, 133 | })), 134 | groupByDevice: clientsArray.reduce((acc, curr) => { 135 | if (!acc[curr.device]) { 136 | acc[curr.device] = 1; 137 | } 138 | 139 | acc[curr.device] += 1; 140 | 141 | return acc; 142 | }, {}) as { [device: string]: number }, 143 | }; 144 | } 145 | 146 | /** 147 | * Конфигурируем воркер. 148 | * @returns {Promise} Promise 149 | */ 150 | public async load(): Promise { 151 | try { 152 | await this.configureWorker(); 153 | } catch (error) { 154 | this.logger.error(error.message, error.stack, 'WssRoom - load'); 155 | } 156 | } 157 | 158 | /** 159 | * Закрывает комнату убивая все соединения с ней. 160 | * @returns {void} void 161 | */ 162 | public close(): void { 163 | try { 164 | this.clients.forEach(user => { 165 | const { io: client, media, id } = user; 166 | 167 | if (client) { 168 | client.broadcast.to(this.session_id).emit('mediaDisconnectMember', { id }); 169 | client.leave(this.session_id); 170 | } 171 | 172 | if (media) { 173 | this.closeMediaClient(media); 174 | } 175 | }); 176 | this.clients.clear(); 177 | this.audioLevelObserver.close(); 178 | this.router.close(); 179 | 180 | this.logger.info(`room ${this.session_id} closed`); 181 | } catch (error) { 182 | this.logger.error(error.message, error.stack, 'WssRoom - close'); 183 | } 184 | } 185 | 186 | /** 187 | * Меняет воркер в комнате. 188 | * @param {IWorker} worker воркер 189 | * @param {number} index индекс воркера 190 | * @returns {Promise} Promise 191 | */ 192 | public async reConfigureMedia(worker: Worker, index: number): Promise { 193 | try { 194 | this.clients.forEach(user => { 195 | const { media } = user; 196 | 197 | if (media) { 198 | this.closeMediaClient(media); 199 | user.media = {}; 200 | } 201 | }); 202 | 203 | this.audioLevelObserver.close(); 204 | this.router.close(); 205 | 206 | this.worker = worker; 207 | this.workerIndex = index; 208 | 209 | await this.configureWorker(); 210 | 211 | this.broadcastAll('mediaReconfigure', {}); 212 | } catch (error) { 213 | this.logger.error(error.message, error.stack, 'WssRoom - reConfigureMedia'); 214 | } 215 | } 216 | 217 | /** 218 | * Отправляет сообщения от клиента всем в комнату. 219 | * @param {io.Socket} client клиент 220 | * @param {string} event ивент из сообщения 221 | * @param {msg} msg сообщение клиента 222 | * @returns {boolean} boolean 223 | */ 224 | public broadcast(client: io.Socket, event: string, msg: object): boolean { 225 | try { 226 | return client.broadcast.to(this.session_id).emit(event, msg); 227 | } catch (error) { 228 | this.logger.error(error.message, error.stack, 'WssRoom - broadcast'); 229 | } 230 | } 231 | 232 | /** 233 | * Отправляет сообщения от клиента всем в комнату включая его. 234 | * @param {string} event ивент из сообщения 235 | * @param {msg} msg сообщение клиента 236 | * @returns {boolean} boolean 237 | */ 238 | public broadcastAll(event: string, msg: object): boolean { 239 | try { 240 | return this.wssServer.to(this.session_id).emit(event, msg); 241 | } catch (error) { 242 | this.logger.error(error.message, error.stack, 'WssRoom - broadcastAll'); 243 | } 244 | } 245 | 246 | /** 247 | * Убивает все соединения с медиасупом для клиента. 248 | * @param {IMediasoupClient} mediaClient данные из комнату по медиасупу клиенту 249 | * @returns {boolean} boolean 250 | */ 251 | private closeMediaClient(mediaClient: IMediasoupClient): boolean { 252 | try { 253 | if (mediaClient.producerVideo && !mediaClient.producerVideo.closed) { 254 | mediaClient.producerVideo.close(); 255 | } 256 | if (mediaClient.producerAudio && !mediaClient.producerAudio.closed) { 257 | mediaClient.producerAudio.close(); 258 | } 259 | if (mediaClient.producerTransport && !mediaClient.producerTransport.closed) { 260 | mediaClient.producerTransport.close(); 261 | } 262 | if (mediaClient.consumerTransport && !mediaClient.consumerTransport.closed) { 263 | mediaClient.consumerTransport.close(); 264 | } 265 | 266 | return true; 267 | } catch (error) { 268 | this.logger.error(error.message, error.stack, 'WssRoom - closeMediaClient'); 269 | } 270 | } 271 | 272 | /** 273 | * Добавляет юзера в комнату. 274 | * @param {IClientQuery} query query клиента 275 | * @param {io.Socket} client клиент 276 | * @returns {Promise} Promise 277 | */ 278 | public async addClient(query: IClientQuery, client: io.Socket): Promise { 279 | try { 280 | this.logger.info(`${query.user_id} connected to room ${this.session_id}`); 281 | 282 | this.clients.set(query.user_id, { io: client, id: query.user_id, device: query.device, media: {} }); 283 | 284 | client.join(this.session_id); 285 | 286 | this.broadcastAll('mediaClientConnected', { 287 | id: query.user_id, 288 | }); 289 | 290 | return true; 291 | } catch (error) { 292 | this.logger.error(error.message, error.stack, 'WssRoom - addClient'); 293 | } 294 | } 295 | 296 | /** 297 | * Удаляет юзера из комнаты. 298 | * @param {string} user_id юзера 299 | * @returns {Promise} Promise 300 | */ 301 | public async removeClient(user_id: string): Promise { 302 | try { 303 | this.logger.info(`${user_id} disconnected from room ${this.session_id}`); 304 | 305 | const user = this.clients.get(user_id); 306 | 307 | if (user) { 308 | const { io: client, media, id } = user; 309 | 310 | if (client) { 311 | this.broadcast(client, 'mediaClientDisconnect', { id }); 312 | 313 | client.leave(this.session_id); 314 | } 315 | 316 | if (media) { 317 | this.closeMediaClient(media); 318 | } 319 | 320 | this.clients.delete(user_id); 321 | } 322 | 323 | return true; 324 | } catch (error) { 325 | this.logger.error(error.message, error.stack, 'WssRoom - removeClient'); 326 | } 327 | } 328 | 329 | /** 330 | * Обрабатывает сообщение. 331 | * @param {string} user_id автор сообщения 332 | * @param {IMsMessage} msg сообщение 333 | * @returns {Promise} Promise 334 | */ 335 | public async speakMsClient(user_id: string, msg: IMsMessage): Promise { 336 | try { 337 | switch (msg.action) { 338 | case 'getRouterRtpCapabilities': 339 | return { 340 | routerRtpCapabilities: this.getRouterRtpCapabilities, 341 | }; 342 | case 'createWebRtcTransport': 343 | return await this.createWebRtcTransport(msg.data as { type: TPeer }, user_id); 344 | case 'connectWebRtcTransport': 345 | return await this.connectWebRtcTransport( 346 | msg.data as { dtlsParameters: DtlsParameters; type: TPeer }, 347 | user_id 348 | ); 349 | case 'produce': 350 | return await this.produce(msg.data as { rtpParameters: RTCRtpParameters; kind: MediaKind }, user_id); 351 | case 'consume': 352 | return await this.consume( 353 | msg.data as { rtpCapabilities: RtpCapabilities; user_id: string; kind: MediaKind }, 354 | user_id 355 | ); 356 | case 'restartIce': 357 | return await this.restartIce(msg.data as { type: TPeer }, user_id); 358 | case 'requestConsumerKeyFrame': 359 | return await this.requestConsumerKeyFrame(msg.data as { user_id: string }, user_id); 360 | case 'getTransportStats': 361 | return await this.getTransportStats(msg.data as { type: TPeer }, user_id); 362 | case 'getProducerStats': 363 | return await this.getProducerStats(msg.data as { user_id: string; kind: MediaKind }, user_id); 364 | case 'getConsumerStats': 365 | return await this.getConsumerStats(msg.data as { user_id: string; kind: MediaKind }, user_id); 366 | case 'getAudioProducerIds': 367 | return await this.getAudioProducerIds(user_id); 368 | case 'getVideoProducerIds': 369 | return await this.getVideoProducerIds(user_id); 370 | case 'producerClose': 371 | return await this.producerClose(msg.data as { user_id: string; kind: MediaKind }, user_id); 372 | case 'producerPause': 373 | return await this.producerPause(msg.data as { user_id: string; kind: MediaKind }, user_id); 374 | case 'producerResume': 375 | return await this.producerResume(msg.data as { user_id: string; kind: MediaKind }, user_id); 376 | case 'allProducerClose': 377 | return await this.allProducerClose(msg.data as { kind: MediaKind }, user_id); 378 | case 'allProducerPause': 379 | return await this.allProducerPause(msg.data as { kind: MediaKind }, user_id); 380 | case 'allProducerResume': 381 | return await this.allProducerResume(msg.data as { kind: MediaKind }, user_id); 382 | } 383 | 384 | throw new Error(`Couldn't find Mediasoup Event with 'name'=${msg.action}`); 385 | } catch (error) { 386 | this.logger.error(error.message, error.stack, 'MediasoupHelper - commit'); 387 | return false; 388 | } 389 | } 390 | 391 | /** 392 | * Создает WebRTC транспорт для приема или передачи стрима. 393 | * @param {object} data { type: TPeer } 394 | * @param {string} user_id автор сообщения 395 | * @returns {Promise} Promise 396 | */ 397 | private async createWebRtcTransport(data: { type: TPeer }, user_id: string): Promise { 398 | try { 399 | this.logger.info(`room ${this.session_id} createWebRtcTransport - ${data.type}`); 400 | 401 | const user = this.clients.get(user_id); 402 | 403 | const { initialAvailableOutgoingBitrate } = mediasoupSettings.webRtcTransport; 404 | 405 | const transport = await this.router.createWebRtcTransport({ 406 | listenIps: mediasoupSettings.webRtcTransport.listenIps, 407 | enableUdp: true, 408 | enableSctp: true, 409 | enableTcp: true, 410 | initialAvailableOutgoingBitrate, 411 | appData: { user_id, type: data.type }, 412 | }); 413 | 414 | switch (data.type) { 415 | case 'producer': 416 | user.media.producerTransport = transport; 417 | break; 418 | case 'consumer': 419 | user.media.consumerTransport = transport; 420 | break; 421 | } 422 | 423 | await this.updateMaxIncomingBitrate(); 424 | 425 | return { 426 | params: { 427 | id: transport.id, 428 | iceParameters: transport.iceParameters, 429 | iceCandidates: transport.iceCandidates, 430 | dtlsParameters: transport.dtlsParameters, 431 | }, 432 | type: data.type, 433 | }; 434 | } catch (error) { 435 | this.logger.error(error.message, error.stack, 'MediasoupHelper - createWebRtcTransport'); 436 | } 437 | } 438 | 439 | /** 440 | * Подключает WebRTC транспорт. 441 | * @param {object} data { dtlsParameters: RTCDtlsParameters; type: TPeer } 442 | * @param {string} user_id автор сообщения 443 | * @returns {Promise} Promise 444 | */ 445 | private async connectWebRtcTransport( 446 | data: { dtlsParameters: DtlsParameters; type: TPeer }, 447 | user_id: string 448 | ): Promise { 449 | try { 450 | this.logger.info(`room ${this.session_id} connectWebRtcTransport - ${data.type}`); 451 | 452 | const user = this.clients.get(user_id); 453 | 454 | let transport: WebRtcTransport; 455 | 456 | switch (data.type) { 457 | case 'producer': 458 | transport = user.media.producerTransport; 459 | break; 460 | case 'consumer': 461 | transport = user.media.consumerTransport; 462 | break; 463 | } 464 | 465 | if (!transport) { 466 | throw new Error( 467 | `Couldn't find ${data.type} transport with 'user_id'=${user_id} and 'room_id'=${this.session_id}` 468 | ); 469 | } 470 | 471 | await transport.connect({ dtlsParameters: data.dtlsParameters }); 472 | 473 | return {}; 474 | } catch (error) { 475 | this.logger.error(error.message, error.stack, 'MediasoupHelper - connectWebRtcTransport'); 476 | } 477 | } 478 | 479 | /** 480 | * Принимает стрим видео или аудио от пользователя. 481 | * @param {object} data { rtpParameters: RTCRtpParameters; kind: MediaKind } 482 | * @param {string} user_id автор сообщения 483 | * @returns {Promise} Promise 484 | */ 485 | private async produce(data: { rtpParameters: RTCRtpParameters; kind: MediaKind }, user_id: string): Promise { 486 | try { 487 | this.logger.info(`room ${this.session_id} produce - ${data.kind}`); 488 | 489 | const user = this.clients.get(user_id); 490 | 491 | const transport = user.media.producerTransport; 492 | 493 | if (!transport) { 494 | throw new Error(`Couldn't find producer transport with 'user_id'=${user_id} and 'room_id'=${this.session_id}`); 495 | } 496 | 497 | const producer = await transport.produce({ ...data, appData: { user_id, kind: data.kind } }); 498 | 499 | switch (data.kind) { 500 | case 'video': 501 | user.media.producerVideo = producer; 502 | break; 503 | case 'audio': 504 | user.media.producerAudio = producer; 505 | await this.audioLevelObserver.addProducer({ producerId: producer.id }); 506 | break; 507 | } 508 | 509 | this.broadcast(user.io, 'mediaProduce', { user_id, kind: data.kind }); 510 | 511 | if (data.kind === 'video') { 512 | producer.on('videoorientationchange', (videoOrientation: ProducerVideoOrientation) => { 513 | this.broadcastAll('mediaVideoOrientationChange', { user_id, videoOrientation }); 514 | }); 515 | } 516 | 517 | producer.on('score', (score: ProducerScore[]) => { 518 | this.logger.info( 519 | `room ${this.session_id} user ${user_id} producer ${data.kind} score ${JSON.stringify(score)}` 520 | ); 521 | }); 522 | 523 | return {}; 524 | } catch (error) { 525 | this.logger.error(error.message, error.stack, 'MediasoupHelper - produce'); 526 | } 527 | } 528 | 529 | /** 530 | * Передает стрим видео или аудио от одного пользователя другому. 531 | * @param {object} data { rtpCapabilities: RTCRtpCapabilities; user_id: string; kind: MediaKind } 532 | * @param {string} user_id автор сообщения 533 | * @returns {Promise} Promise 534 | */ 535 | private async consume( 536 | data: { rtpCapabilities: RtpCapabilities; user_id: string; kind: MediaKind }, 537 | user_id: string 538 | ): Promise { 539 | try { 540 | this.logger.info(`room ${this.session_id} produce - ${data.kind}`); 541 | 542 | const user = this.clients.get(user_id); 543 | const target = this.clients.get(data.user_id); 544 | 545 | let target_producer: Producer; 546 | 547 | switch (data.kind) { 548 | case 'video': 549 | target_producer = target.media.producerVideo; 550 | break; 551 | case 'audio': 552 | target_producer = target.media.producerAudio; 553 | break; 554 | } 555 | 556 | if ( 557 | !target_producer || 558 | !data.rtpCapabilities || 559 | !this.router.canConsume({ 560 | producerId: target_producer.id, 561 | rtpCapabilities: data.rtpCapabilities, 562 | }) 563 | ) { 564 | throw new Error( 565 | `Couldn't consume ${data.kind} with 'user_id'=${data.user_id} and 'room_id'=${this.session_id}` 566 | ); 567 | } 568 | 569 | const transport = user.media.consumerTransport; 570 | 571 | if (!transport) { 572 | throw new Error(`Couldn't find consumer transport with 'user_id'=${user_id} and 'room_id'=${this.session_id}`); 573 | } 574 | 575 | const consumer = await transport.consume({ 576 | producerId: target_producer.id, 577 | rtpCapabilities: data.rtpCapabilities, 578 | paused: data.kind === 'video', 579 | appData: { user_id, kind: data.kind, producer_user_id: data.user_id }, 580 | }); 581 | 582 | switch (data.kind) { 583 | case 'video': 584 | if (!user.media.consumersVideo) { 585 | user.media.consumersVideo = new Map(); 586 | } 587 | 588 | user.media.consumersVideo.set(data.user_id, consumer); 589 | 590 | consumer.on('transportclose', async () => { 591 | consumer.close(); 592 | user.media.consumersVideo.delete(data.user_id); 593 | }); 594 | 595 | consumer.on('producerclose', async () => { 596 | user.io.emit('mediaProducerClose', { user_id: data.user_id, kind: data.kind }); 597 | consumer.close(); 598 | user.media.consumersVideo.delete(data.user_id); 599 | }); 600 | break; 601 | case 'audio': 602 | if (!user.media.consumersAudio) { 603 | user.media.consumersAudio = new Map(); 604 | } 605 | 606 | user.media.consumersAudio.set(data.user_id, consumer); 607 | 608 | consumer.on('transportclose', async () => { 609 | consumer.close(); 610 | user.media.consumersAudio.delete(data.user_id); 611 | }); 612 | 613 | consumer.on('producerclose', async () => { 614 | user.io.emit('mediaProducerClose', { user_id: data.user_id, kind: data.kind }); 615 | consumer.close(); 616 | user.media.consumersAudio.delete(data.user_id); 617 | }); 618 | break; 619 | } 620 | 621 | consumer.on('producerpause', async () => { 622 | await consumer.pause(); 623 | user.io.emit('mediaProducerPause', { user_id: data.user_id, kind: data.kind }); 624 | }); 625 | 626 | consumer.on('producerresume', async () => { 627 | await consumer.resume(); 628 | user.io.emit('mediaProducerResume', { user_id: data.user_id, kind: data.kind }); 629 | }); 630 | 631 | consumer.on('score', (score: ConsumerScore[]) => { 632 | this.logger.info( 633 | `room ${this.session_id} user ${user_id} consumer ${data.kind} score ${JSON.stringify(score)}` 634 | ); 635 | }); 636 | 637 | consumer.on('layerschange', (layers: ConsumerLayers | null) => { 638 | this.logger.info( 639 | `room ${this.session_id} user ${user_id} consumer ${data.kind} layerschange ${JSON.stringify(layers)}` 640 | ); 641 | }); 642 | 643 | if (consumer.kind === 'video') { 644 | await consumer.resume(); 645 | } 646 | 647 | return { 648 | producerId: target_producer.id, 649 | id: consumer.id, 650 | kind: consumer.kind, 651 | rtpParameters: consumer.rtpParameters, 652 | type: consumer.type, 653 | producerPaused: consumer.producerPaused, 654 | }; 655 | } catch (error) { 656 | this.logger.error(error.message, error.stack, 'MediasoupHelper - consume'); 657 | } 658 | } 659 | 660 | /** 661 | * Перезапустить соединительные узлы. 662 | * @param {object} data { type: TPeer } 663 | * https://developer.mozilla.org/ru/docs/Web/API/WebRTC_API/%D0%BF%D1%80%D0%BE%D1%82%D0%BE%D0%BA%D0%BE%D0%BB%D1%8B 664 | * @param {string} user_id автор сообщения 665 | * @returns {Promise} Promise 666 | */ 667 | private async restartIce(data: { type: TPeer }, user_id: string): Promise { 668 | try { 669 | this.logger.info(`room ${this.session_id} restartIce - ${data.type}`); 670 | 671 | const user = this.clients.get(user_id); 672 | 673 | let transport: WebRtcTransport; 674 | 675 | switch (data.type) { 676 | case 'producer': 677 | transport = user.media.producerTransport; 678 | break; 679 | case 'consumer': 680 | transport = user.media.consumerTransport; 681 | break; 682 | } 683 | 684 | if (!transport) { 685 | throw new Error( 686 | `Couldn't find ${data.type} transport with 'user_id'=${user_id} and 'room_id'=${this.session_id}` 687 | ); 688 | } 689 | 690 | const iceParameters = await transport.restartIce(); 691 | 692 | return { ...iceParameters }; 693 | } catch (error) { 694 | this.logger.error(error.message, error.stack, 'MediasoupHelper - restartIce'); 695 | } 696 | } 697 | 698 | /** 699 | * Запросить опорный кадр. 700 | * @param {object} data { user_id: string } 701 | * @param {string} user_id автор сообщения 702 | * @returns {Promise} Promise 703 | */ 704 | private async requestConsumerKeyFrame(data: { user_id: string }, user_id: string): Promise { 705 | try { 706 | const user = this.clients.get(user_id); 707 | 708 | const consumer: Consumer = user.media.consumersVideo.get(data.user_id); 709 | 710 | if (!consumer) { 711 | throw new Error(`Couldn't find video consumer with 'user_id'=${data.user_id} and 'room_id'=${this.session_id}`); 712 | } 713 | 714 | await consumer.requestKeyFrame(); 715 | 716 | return true; 717 | } catch (error) { 718 | this.logger.error(error.message, error.stack, 'MediasoupHelper - requestConsumerKeyFrame'); 719 | } 720 | } 721 | 722 | /** 723 | * Отдает стату транспорта. 724 | * @param {object} data { type: TPeer } 725 | * @param {string} user_id автор сообщения 726 | * @returns {Promise} Promise 727 | */ 728 | private async getTransportStats(data: { type: TPeer }, user_id: string): Promise { 729 | try { 730 | this.logger.info(`room ${this.session_id} getTransportStats - ${data.type}`); 731 | 732 | const user = this.clients.get(user_id); 733 | 734 | let transport: WebRtcTransport; 735 | 736 | switch (data.type) { 737 | case 'producer': 738 | transport = user.media.producerTransport; 739 | break; 740 | case 'consumer': 741 | transport = user.media.consumerTransport; 742 | break; 743 | } 744 | 745 | if (!transport) { 746 | throw new Error( 747 | `Couldn't find ${data.type} transport with 'user_id'=${user_id} and 'room_id'=${this.session_id}` 748 | ); 749 | } 750 | 751 | const stats = await transport.getStats(); 752 | 753 | return { ...data, stats }; 754 | } catch (error) { 755 | this.logger.error(error.message, error.stack, 'MediasoupHelper - getTransportStats'); 756 | } 757 | } 758 | 759 | /** 760 | * Отдает инфу о стриме юзера 761 | * Замер происходит когда от юзера приходит стрим на сервер. 762 | * @param {object} data { user_id: string; kind: MediaKind } 763 | * @param {string} _user_id автор сообщения 764 | * @returns {Promise} Promise 765 | */ 766 | private async getProducerStats(data: { user_id: string; kind: MediaKind }, _user_id: string): Promise { 767 | try { 768 | this.logger.info(`room ${this.session_id} getProducerStats - ${data.kind}`); 769 | 770 | const target_user = this.clients.get(data.user_id); 771 | 772 | let producer: Producer; 773 | 774 | switch (data.kind) { 775 | case 'video': 776 | producer = target_user.media.producerVideo; 777 | break; 778 | case 'audio': 779 | producer = target_user.media.producerAudio; 780 | break; 781 | } 782 | 783 | if (!producer) { 784 | throw new Error( 785 | `Couldn't find ${data.kind} producer with 'user_id'=${data.user_id} and 'room_id'=${this.session_id}` 786 | ); 787 | } 788 | 789 | const stats = await producer.getStats(); 790 | 791 | return { ...data, stats }; 792 | } catch (error) { 793 | this.logger.error(error.message, error.stack, 'MediasoupHelper - getProducerStats'); 794 | } 795 | } 796 | 797 | /** 798 | * Отдает инфу о стриме юзера на которого подписан текущий юзер 799 | * Замер происходит когда от того юзера передается стрим текущему юзеру. 800 | * @param {object} data { user_id: string; kind: MediaKind } 801 | * @param {string} user_id автор сообщения 802 | * @returns {Promise} Promise 803 | */ 804 | private async getConsumerStats(data: { user_id: string; kind: MediaKind }, user_id: string): Promise { 805 | try { 806 | this.logger.info(`room ${this.session_id} getProducerStats - ${data.kind}`); 807 | 808 | const user = this.clients.get(user_id); 809 | 810 | let consumer: Consumer; 811 | 812 | switch (data.kind) { 813 | case 'video': 814 | consumer = user.media.consumersVideo.get(data.user_id); 815 | break; 816 | case 'audio': 817 | consumer = user.media.consumersAudio.get(data.user_id); 818 | break; 819 | } 820 | 821 | if (!consumer) { 822 | throw new Error( 823 | `Couldn't find ${data.kind} consumer with 'user_id'=${data.user_id} and 'room_id'=${this.session_id}` 824 | ); 825 | } 826 | 827 | const stats = await consumer.getStats(); 828 | 829 | return { ...data, stats }; 830 | } catch (error) { 831 | this.logger.error(error.message, error.stack, 'MediasoupHelper - getConsumerStats'); 832 | } 833 | } 834 | 835 | /** 836 | * Id юзеров которые передаеют стримы на сервер. 837 | * @param {string} _user_id автор сообщения 838 | * @returns {Promise} Promise 839 | */ 840 | private async getVideoProducerIds(_user_id: string): Promise { 841 | try { 842 | return this.videoProducerIds; 843 | } catch (error) { 844 | this.logger.error(error.message, error.stack, 'MediasoupHelper - getVideoProducerIds'); 845 | } 846 | } 847 | 848 | /** 849 | * Id юзеров которые передаеют стримы на сервер. 850 | * @param {string} _user_id автор сообщения 851 | * @returns {Promise} Promise 852 | */ 853 | private async getAudioProducerIds(_user_id: string): Promise { 854 | try { 855 | return this.audioProducerIds; 856 | } catch (error) { 857 | this.logger.error(error.message, error.stack, 'MediasoupHelper - getAudioProducerIds'); 858 | } 859 | } 860 | 861 | /** 862 | * Остановить передачу стрима на сервер от пользователя. 863 | * @param {object} data { user_id: string; kind: MediaKind } 864 | * @param {string} _user_id автор сообщения 865 | * @returns {Promise} promise 866 | */ 867 | private async producerClose(data: { user_id: string; kind: MediaKind }, _user_id: string): Promise { 868 | try { 869 | const target_user = this.clients.get(data.user_id); 870 | 871 | if (target_user) { 872 | let target_producer: Producer; 873 | 874 | switch (data.kind) { 875 | case 'video': 876 | target_producer = target_user.media.producerVideo; 877 | break; 878 | case 'audio': 879 | target_producer = target_user.media.producerAudio; 880 | break; 881 | } 882 | 883 | if (target_producer && !target_producer.closed) { 884 | target_producer.close(); 885 | } 886 | } 887 | 888 | return true; 889 | } catch (error) { 890 | this.logger.error(error.message, error.stack, 'MediasoupHelper - producerClose'); 891 | } 892 | } 893 | 894 | /** 895 | * Приостановить передачу стрима на сервер от пользователя. 896 | * @param {object} data { user_id: string; kind: MediaKind } 897 | * @param {string} _user_id автор сообщения 898 | * @returns {Promise} promise 899 | */ 900 | private async producerPause(data: { user_id: string; kind: MediaKind }, _user_id: string): Promise { 901 | try { 902 | const target_user = this.clients.get(data.user_id); 903 | 904 | if (target_user) { 905 | let target_producer: Producer; 906 | 907 | switch (data.kind) { 908 | case 'video': 909 | target_producer = target_user.media.producerVideo; 910 | break; 911 | case 'audio': 912 | target_producer = target_user.media.producerAudio; 913 | break; 914 | } 915 | 916 | if (target_producer && !target_producer.paused) { 917 | await target_producer.pause(); 918 | } 919 | } 920 | 921 | return true; 922 | } catch (error) { 923 | this.logger.error(error.message, error.stack, 'MediasoupHelper - producerPause'); 924 | } 925 | } 926 | 927 | /** 928 | * Возобновить передачу стрима на сервер от пользователя. 929 | * @param {object} data { user_id: string; kind: MediaKind } 930 | * @param {string} _user_id автор сообщения 931 | * @returns {Promise} promise 932 | */ 933 | private async producerResume(data: { user_id: string; kind: MediaKind }, _user_id: string): Promise { 934 | try { 935 | const target_user = this.clients.get(data.user_id); 936 | 937 | if (target_user) { 938 | let target_producer: Producer; 939 | 940 | switch (data.kind) { 941 | case 'video': 942 | target_producer = target_user.media.producerVideo; 943 | break; 944 | case 'audio': 945 | target_producer = target_user.media.producerAudio; 946 | break; 947 | } 948 | 949 | if (target_producer && target_producer.paused && !target_producer.closed) { 950 | await target_producer.resume(); 951 | } else if (target_producer && target_producer.closed) { 952 | target_user.io.emit('mediaReproduce', { kind: data.kind }); 953 | } 954 | } 955 | 956 | return true; 957 | } catch (error) { 958 | this.logger.error(error.message, error.stack, 'MediasoupHelper - producerResume'); 959 | } 960 | } 961 | 962 | /** 963 | * Остановить передачу стрима на сервер от всех пользователей. 964 | * @param {object} data { kind: MediaKind } 965 | * @param {string} _user_id автор сообщения 966 | * @returns {Promise} promise 967 | */ 968 | private async allProducerClose(data: { kind: MediaKind }, _user_id: string): Promise { 969 | try { 970 | this.clients.forEach(async client => { 971 | if (client.media) { 972 | let target_producer: Producer; 973 | 974 | switch (data.kind) { 975 | case 'video': 976 | target_producer = client.media.producerVideo; 977 | break; 978 | case 'audio': 979 | target_producer = client.media.producerAudio; 980 | break; 981 | } 982 | 983 | if (target_producer && !target_producer.closed) { 984 | target_producer.close(); 985 | } 986 | } 987 | }); 988 | 989 | return true; 990 | } catch (error) { 991 | this.logger.error(error.message, error.stack, 'MediasoupHelper - allProducerClose'); 992 | } 993 | } 994 | 995 | /** 996 | * Приостановить передачу стрима на сервер от всех пользователей. 997 | * @param {object} data { kind: MediaKind } 998 | * @param {string} _user_id автор сообщения 999 | * @returns {Promise} promise 1000 | */ 1001 | private async allProducerPause(data: { kind: MediaKind }, _user_id: string): Promise { 1002 | try { 1003 | this.clients.forEach(async client => { 1004 | if (client.media) { 1005 | let target_producer: Producer; 1006 | 1007 | switch (data.kind) { 1008 | case 'video': 1009 | target_producer = client.media.producerVideo; 1010 | break; 1011 | case 'audio': 1012 | target_producer = client.media.producerAudio; 1013 | break; 1014 | } 1015 | 1016 | if (target_producer && !target_producer.paused) { 1017 | await target_producer.pause(); 1018 | } 1019 | } 1020 | }); 1021 | 1022 | return true; 1023 | } catch (error) { 1024 | this.logger.error(error.message, error.stack, 'MediasoupHelper - allProducerPause'); 1025 | } 1026 | } 1027 | 1028 | /** 1029 | * Возобновить передачу стрима на сервер от всех пользователей. 1030 | * @param {object} data { kind: MediaKind } 1031 | * @param {string} _user_id автор сообщения 1032 | * @returns {Promise} promise 1033 | */ 1034 | private async allProducerResume(data: { kind: MediaKind }, _user_id: string): Promise { 1035 | try { 1036 | this.clients.forEach(async client => { 1037 | if (client.media) { 1038 | let target_producer: Producer; 1039 | 1040 | switch (data.kind) { 1041 | case 'video': 1042 | target_producer = client.media.producerVideo; 1043 | break; 1044 | case 'audio': 1045 | target_producer = client.media.producerAudio; 1046 | break; 1047 | } 1048 | 1049 | if (target_producer && target_producer.paused && !target_producer.closed) { 1050 | await target_producer.resume(); 1051 | } else if (target_producer && target_producer.closed) { 1052 | client.io.emit('mediaReproduce', { kind: data.kind }); 1053 | } 1054 | } 1055 | }); 1056 | 1057 | return true; 1058 | } catch (error) { 1059 | this.logger.error(error.message, error.stack, 'MediasoupHelper - allProducerResume'); 1060 | } 1061 | } 1062 | 1063 | /** 1064 | * Изменяет качество стрима. 1065 | * @returns {Promise} Promise 1066 | */ 1067 | private async updateMaxIncomingBitrate(): Promise { 1068 | try { 1069 | const { 1070 | minimumAvailableOutgoingBitrate, 1071 | maximumAvailableOutgoingBitrate, 1072 | factorIncomingBitrate, 1073 | } = mediasoupSettings.webRtcTransport; 1074 | 1075 | let newMaxIncomingBitrate = Math.round( 1076 | maximumAvailableOutgoingBitrate / ((this.producerIds.length - 1) * factorIncomingBitrate) 1077 | ); 1078 | 1079 | if (newMaxIncomingBitrate < minimumAvailableOutgoingBitrate) { 1080 | newMaxIncomingBitrate = minimumAvailableOutgoingBitrate; 1081 | } 1082 | 1083 | if (this.producerIds.length < 3) { 1084 | newMaxIncomingBitrate = maximumAvailableOutgoingBitrate; 1085 | } 1086 | 1087 | this.clients.forEach(client => { 1088 | if (client.media) { 1089 | if (client.media.producerTransport && !client.media.producerTransport.closed) { 1090 | client.media.producerTransport.setMaxIncomingBitrate(newMaxIncomingBitrate); 1091 | } 1092 | if (client.media.consumerTransport && !client.media.consumerTransport.closed) { 1093 | client.media.consumerTransport.setMaxIncomingBitrate(newMaxIncomingBitrate); 1094 | } 1095 | } 1096 | }); 1097 | 1098 | return true; 1099 | } catch (error) { 1100 | this.logger.error(error.message, error.stack, 'MediasoupHelper - updateMaxBitrate'); 1101 | } 1102 | } 1103 | } 1104 | --------------------------------------------------------------------------------