├── server ├── src │ ├── common │ │ ├── mock │ │ │ └── mock.ts │ │ └── logger │ │ │ └── winston.util.ts │ ├── signaling │ │ ├── dto │ │ │ ├── leave-channel.dts.ts │ │ │ └── join-channel.dto.ts │ │ ├── signaling.module.ts │ │ ├── signaling.gateway.spec.ts │ │ └── signaling.gateway.ts │ ├── constants │ │ └── role.enum.ts │ ├── app.service.ts │ ├── decorators │ │ └── roles.decorator.ts │ ├── mediasoup │ │ ├── room │ │ │ ├── room.interface.ts │ │ │ ├── peer.interface.ts │ │ │ ├── room.module.ts │ │ │ └── room.service.ts │ │ ├── transport │ │ │ ├── transport.interface.ts │ │ │ ├── transport.module.ts │ │ │ └── transport.service.ts │ │ ├── producer-consumer │ │ │ ├── producer-consumer.module.ts │ │ │ ├── producer-consumer.interface.ts │ │ │ └── producer-consumer.service.ts │ │ ├── mediasoup.module.ts │ │ ├── media.config.ts │ │ ├── interface │ │ │ ├── media-resources.interfaces.ts │ │ │ └── user-resources.interfaces.ts │ │ └── mediasoup.service.ts │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.controller.spec.ts │ ├── guards │ │ └── Roles.guard.ts │ └── main.ts ├── .prettierrc ├── tsconfig.build.json ├── .env ├── nest-cli.json ├── test │ ├── jest-e2e.json │ └── app.e2e-spec.ts ├── .gitignore ├── tsconfig.json ├── .eslintrc.js ├── package.json └── README.md ├── image.png ├── client ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── src │ ├── index.js │ ├── App.css │ ├── index.css │ └── App.jsx ├── .gitignore ├── package.json └── README.md └── README.md /server/src/common/mock/mock.ts: -------------------------------------------------------------------------------- 1 | export const rooms = {}; 2 | -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /server/src/signaling/dto/leave-channel.dts.ts: -------------------------------------------------------------------------------- 1 | export class LeaveChannelDto {} 2 | -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smaivnn/mediasoup-tutorial-react-nestjs/HEAD/image.png -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /server/src/constants/role.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | User = 'user', 3 | Admin = 'admin', 4 | } 5 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smaivnn/mediasoup-tutorial-react-nestjs/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smaivnn/mediasoup-tutorial-react-nestjs/HEAD/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smaivnn/mediasoup-tutorial-react-nestjs/HEAD/client/public/logo512.png -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /server/.env: -------------------------------------------------------------------------------- 1 | # app 2 | NODE_ENV=development 3 | DOMAIN=http://localhost 4 | PORT=5000 5 | SECRET_KEY=secretKey 6 | 7 | CORS_ORIGIN_LIST=https://localhost:3000 -------------------------------------------------------------------------------- /server/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById("root")); 7 | root.render(); 8 | -------------------------------------------------------------------------------- /server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { Role } from '../constants/role.enum'; 3 | 4 | export const ROLES_KEY = 'roles'; 5 | export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); 6 | -------------------------------------------------------------------------------- /server/src/mediasoup/room/room.interface.ts: -------------------------------------------------------------------------------- 1 | import { IRouter } from '../interface/media-resources.interfaces'; 2 | import { Peer } from './peer.interface'; 3 | 4 | export interface IRoom { 5 | id: string; 6 | router: IRouter; 7 | peers: Map; 8 | } 9 | -------------------------------------------------------------------------------- /server/src/signaling/dto/join-channel.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class JoinChannelDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | roomId: string; 7 | 8 | @IsNotEmpty() 9 | @IsString() 10 | peerId: string; 11 | } 12 | -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | align-items: center; 4 | } 5 | 6 | .video-box { 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | margin-top: 20px; 11 | } 12 | 13 | video { 14 | width: 480px; 15 | height: 360px; 16 | border: 1px solid #000; 17 | } 18 | -------------------------------------------------------------------------------- /server/src/mediasoup/room/peer.interface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IConsumer, 3 | IProducer, 4 | ITransport, 5 | } from '../interface/media-resources.interfaces'; 6 | 7 | export interface Peer { 8 | id: string; 9 | transports: Map; 10 | producers: Map; 11 | consumers: Map; 12 | } 13 | -------------------------------------------------------------------------------- /server/src/signaling/signaling.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SignalingGateway } from './signaling.gateway'; 3 | import { MediasoupModule } from '../mediasoup/mediasoup.module'; 4 | 5 | @Module({ 6 | imports: [MediasoupModule], 7 | providers: [SignalingGateway], 8 | }) 9 | export class SignalingModule {} 10 | -------------------------------------------------------------------------------- /server/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/src/mediasoup/transport/transport.interface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DtlsParameters, 3 | IceCandidate, 4 | IceParameters, 5 | } from 'mediasoup/node/lib/types'; 6 | 7 | export interface ITransportOptions { 8 | id: string; 9 | iceParameters: IceParameters; 10 | iceCandidates: IceCandidate[]; 11 | dtlsParameters: DtlsParameters; 12 | } 13 | -------------------------------------------------------------------------------- /server/src/mediasoup/transport/transport.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TransportService } from './transport.service'; 3 | import { RoomModule } from '../room/room.module'; 4 | 5 | @Module({ 6 | imports: [RoomModule], 7 | providers: [TransportService], 8 | exports: [TransportService], 9 | }) 10 | export class TransportModule {} 11 | -------------------------------------------------------------------------------- /server/src/mediasoup/room/room.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | import { RoomService } from './room.service'; 3 | import { MediasoupModule } from '../mediasoup.module'; 4 | 5 | @Module({ 6 | imports: [forwardRef(() => MediasoupModule)], 7 | providers: [RoomService], 8 | exports: [RoomService], 9 | }) 10 | export class RoomModule {} 11 | -------------------------------------------------------------------------------- /server/src/mediasoup/producer-consumer/producer-consumer.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ProducerConsumerService } from './producer-consumer.service'; 3 | import { RoomModule } from '../room/room.module'; 4 | 5 | @Module({ 6 | imports: [RoomModule], 7 | providers: [ProducerConsumerService], 8 | exports: [ProducerConsumerService], 9 | }) 10 | export class ProducerConsumerModule {} 11 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /server/src/mediasoup/producer-consumer/producer-consumer.interface.ts: -------------------------------------------------------------------------------- 1 | import { RtpParameters, RtpCapabilities } from 'mediasoup/node/lib/types'; 2 | 3 | export interface IProduceParams { 4 | roomId: string; 5 | peerId: string; 6 | kind: 'audio' | 'video'; 7 | rtpParameters: RtpParameters; 8 | transportId: string; 9 | } 10 | 11 | export interface IConsumeParams { 12 | roomId: string; 13 | peerId: string; 14 | producerId: string; 15 | rtpCapabilities: RtpCapabilities; 16 | transportId: string; 17 | } 18 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /server/src/signaling/signaling.gateway.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SignalingGateway } from './signaling.gateway'; 3 | 4 | describe('SignalingGateway', () => { 5 | let gateway: SignalingGateway; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [SignalingGateway], 10 | }).compile(); 11 | 12 | gateway = module.get(SignalingGateway); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(gateway).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /server/src/mediasoup/mediasoup.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MediasoupService } from './mediasoup.service'; 3 | import { RoomModule } from './room/room.module'; 4 | import { TransportModule } from './transport/transport.module'; 5 | import { ProducerConsumerModule } from './producer-consumer/producer-consumer.module'; 6 | 7 | @Module({ 8 | imports: [RoomModule, TransportModule, ProducerConsumerModule], 9 | providers: [MediasoupService], 10 | exports: [ 11 | MediasoupService, 12 | RoomModule, 13 | TransportModule, 14 | ProducerConsumerModule, 15 | ], 16 | }) 17 | export class MediasoupModule {} 18 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { HttpModule } from '@nestjs/axios'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { AppController } from './app.controller'; 5 | import { AppService } from './app.service'; 6 | import { MediasoupModule } from './mediasoup/mediasoup.module'; 7 | import { SignalingModule } from './signaling/signaling.module'; 8 | 9 | @Module({ 10 | imports: [ 11 | ConfigModule.forRoot({ 12 | isGlobal: true, 13 | }), 14 | HttpModule, 15 | MediasoupModule, 16 | SignalingModule, 17 | ], 18 | controllers: [AppController], 19 | providers: [AppService], 20 | }) 21 | export class AppModule {} 22 | -------------------------------------------------------------------------------- /server/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /server/src/guards/Roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { Role } from 'src/constants/role.enum'; 4 | 5 | @Injectable() 6 | export class RolesGuard implements CanActivate { 7 | constructor(private reflector: Reflector) {} 8 | 9 | canActivate(context: ExecutionContext): boolean { 10 | const requiredRoles = this.reflector.getAllAndOverride('roles', [ 11 | context.getHandler(), 12 | context.getClass(), 13 | ]); 14 | if (!requiredRoles) { 15 | return true; 16 | } 17 | const { user } = context.switchToHttp().getRequest(); 18 | return requiredRoles.some((role) => user.roles?.includes(role)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/src/mediasoup/media.config.ts: -------------------------------------------------------------------------------- 1 | import * as mediasoup from 'mediasoup'; 2 | 3 | export const mediaCodecs: mediasoup.types.RtpCodecCapability[] = [ 4 | { 5 | kind: 'audio', 6 | mimeType: 'audio/opus', 7 | clockRate: 48000, 8 | channels: 2, 9 | }, 10 | { 11 | kind: 'video', 12 | mimeType: 'video/VP8', 13 | clockRate: 90000, 14 | parameters: { 15 | 'x-google-start-bitrate': 300, 16 | }, 17 | }, 18 | ]; 19 | 20 | export const webRtcTransport_options: mediasoup.types.WebRtcTransportOptions = { 21 | listenIps: [ 22 | { 23 | ip: process.env.WEBRTC_LISTEN_IP || '127.0.0.1', 24 | announcedIp: process.env.WEBRTC_ANNOUNCED_IP || '127.0.0.1', 25 | }, 26 | ], 27 | enableUdp: true, 28 | enableTcp: true, 29 | preferUdp: true, 30 | }; 31 | -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | 'prettier/prettier': [ 25 | 'error', 26 | { 27 | endOfLine: 'auto', 28 | }, 29 | ], 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /server/src/mediasoup/interface/media-resources.interfaces.ts: -------------------------------------------------------------------------------- 1 | import * as mediasoup from 'mediasoup'; 2 | import { 3 | Consumer, 4 | Producer, 5 | Router, 6 | WebRtcTransport, 7 | Worker, 8 | } from 'mediasoup/node/lib/types'; 9 | 10 | export interface ITransportData { 11 | isConsumer?: boolean; 12 | roomId?: string; 13 | socketId?: string; 14 | produceSocketId?: string; 15 | } 16 | 17 | export interface TransportConnectData { 18 | dtlsParameters: mediasoup.types.DtlsParameters; 19 | isConsumer: boolean; 20 | } 21 | 22 | export interface IWorker { 23 | worker: Worker; 24 | routers: Map; 25 | } 26 | 27 | export interface IRouter { 28 | router: Router; 29 | } 30 | 31 | export interface ITransport { 32 | transport: WebRtcTransport; 33 | } 34 | 35 | export interface IProducer { 36 | producer: Producer; 37 | } 38 | 39 | export interface IConsumer { 40 | consumer: Consumer; 41 | } 42 | -------------------------------------------------------------------------------- /server/src/mediasoup/interface/user-resources.interfaces.ts: -------------------------------------------------------------------------------- 1 | import * as mediasoup from 'mediasoup'; 2 | 3 | export interface ITransportInfo { 4 | sendTransport?: mediasoup.types.Transport; 5 | recvTransport?: Map; 6 | } 7 | 8 | export interface IProducerInfo { 9 | audio?: mediasoup.types.Producer; 10 | camera?: mediasoup.types.Producer; 11 | display?: mediasoup.types.Producer; 12 | } 13 | 14 | export interface IConsumerInfo { 15 | audio?: mediasoup.types.Producer; 16 | camera?: mediasoup.types.Producer; 17 | display?: mediasoup.types.Producer; 18 | } 19 | 20 | export interface IMediaResources { 21 | transports?: ITransportInfo; 22 | producers?: IProducerInfo; 23 | consumers?: Map; 24 | } 25 | 26 | export type IMediaResourcesMap = Map; 27 | 28 | export interface IRoom { 29 | router: mediasoup.types.Router; 30 | users: Set; 31 | } 32 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.17.0", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "axios": "^1.6.5", 10 | "mediasoup-client": "^3.7.2", 11 | "react": "^18.2.0", 12 | "react-dom": "^18.2.0", 13 | "react-scripts": "5.0.1", 14 | "socket.io-client": "^4.7.3", 15 | "web-vitals": "^2.1.4", 16 | "zustand": "^4.5.2" 17 | }, 18 | "scripts": { 19 | "start": "set HTTPS=true&&react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test", 22 | "eject": "react-scripts eject" 23 | }, 24 | "eslintConfig": { 25 | "extends": [ 26 | "react-app", 27 | "react-app/jest" 28 | ] 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /server/src/mediasoup/mediasoup.service.ts: -------------------------------------------------------------------------------- 1 | import { IWorker } from './interface/media-resources.interfaces'; 2 | import { Injectable, OnModuleInit } from '@nestjs/common'; 3 | import * as mediasoup from 'mediasoup'; 4 | import * as os from 'os'; 5 | 6 | @Injectable() 7 | export class MediasoupService implements OnModuleInit { 8 | private nextWorkerIndex = 0; 9 | private workers: IWorker[] = []; 10 | 11 | constructor() {} 12 | 13 | /** 14 | * create mediasoup workers on module init 15 | */ 16 | public async onModuleInit() { 17 | const numWorkers = os.cpus().length; 18 | for (let i = 0; i < numWorkers; ++i) { 19 | await this.createWorker(); 20 | } 21 | } 22 | 23 | private async createWorker() { 24 | const worker = await mediasoup.createWorker({ 25 | rtcMinPort: 6002, 26 | rtcMaxPort: 6202, 27 | }); 28 | 29 | worker.on('died', () => { 30 | console.error('mediasoup worker has died'); 31 | setTimeout(() => process.exit(1), 2000); 32 | }); 33 | 34 | this.workers.push({ worker, routers: new Map() }); 35 | return worker; 36 | } 37 | 38 | public getWorker() { 39 | const worker = this.workers[this.nextWorkerIndex].worker; 40 | this.nextWorkerIndex = (this.nextWorkerIndex + 1) % this.workers.length; 41 | return worker; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/src/common/logger/winston.util.ts: -------------------------------------------------------------------------------- 1 | import * as winston from 'winston'; 2 | import { 3 | utilities as nestWinstonModuleUtilities, 4 | WinstonModule, 5 | } from 'nest-winston'; 6 | import * as winstonDaily from 'winston-daily-rotate-file'; 7 | 8 | const dailyOption = (level: string) => { 9 | return { 10 | level, 11 | datePattern: 'YYYY-MM-DD', 12 | dirname: `./logs/${level}`, 13 | filename: `%DATE%.${level}.log`, 14 | maxFiles: 30, 15 | zippedArchive: true, 16 | format: winston.format.combine( 17 | winston.format.timestamp(), 18 | nestWinstonModuleUtilities.format.nestLike(process.env.NODE_ENV, { 19 | colors: false, 20 | prettyPrint: true, 21 | }), 22 | ), 23 | }; 24 | }; 25 | 26 | export const winstonLogger = WinstonModule.createLogger({ 27 | transports: [ 28 | new winston.transports.Console({ 29 | level: process.env.NODE_ENV === 'production' ? 'info' : 'silly', 30 | format: winston.format.combine( 31 | winston.format.colorize(), // 색상 추가 32 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), // 날짜 포맷 변경 33 | nestWinstonModuleUtilities.format.nestLike('MyApp', { 34 | prettyPrint: true, 35 | }), 36 | ), 37 | }), 38 | new winstonDaily(dailyOption('warn')), 39 | new winstonDaily(dailyOption('error')), 40 | ], 41 | }); 42 | -------------------------------------------------------------------------------- /server/src/mediasoup/transport/transport.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { RoomService } from '../room/room.service'; 3 | import { ITransportOptions } from './transport.interface'; 4 | import { WebRtcTransport } from 'mediasoup/node/lib/types'; 5 | import { webRtcTransport_options } from '../media.config'; 6 | 7 | @Injectable() 8 | export class TransportService { 9 | constructor(private readonly roomService: RoomService) {} 10 | 11 | public async createWebRtcTransport( 12 | roomId: string, 13 | peerId: string, 14 | direction: 'send' | 'recv', 15 | ): Promise { 16 | const room = this.roomService.getRoom(roomId); 17 | if (!room) { 18 | throw new Error(`Room ${roomId} not found`); 19 | } 20 | 21 | const transport: WebRtcTransport = 22 | await room.router.router.createWebRtcTransport({ 23 | ...webRtcTransport_options, 24 | appData: { 25 | peerId, 26 | clientDirection: direction, 27 | }, 28 | }); 29 | 30 | this.roomService.addPeerToRoom(roomId, peerId); 31 | 32 | const peer = room.peers.get(peerId)!; 33 | peer.transports.set(transport.id, { transport }); 34 | 35 | return { 36 | id: transport.id, 37 | iceParameters: transport.iceParameters, 38 | iceCandidates: transport.iceCandidates, 39 | dtlsParameters: transport.dtlsParameters, 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /server/src/mediasoup/room/room.service.ts: -------------------------------------------------------------------------------- 1 | import { mediaCodecs } from './../media.config'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { IRoom } from './room.interface'; 4 | import { MediasoupService } from '../mediasoup.service'; 5 | 6 | @Injectable() 7 | export class RoomService { 8 | private rooms: Map = new Map(); 9 | constructor(private readonly mediasoupService: MediasoupService) {} 10 | 11 | public async createRoom(roomId: string): Promise { 12 | if (this.rooms.has(roomId)) { 13 | return this.rooms.get(roomId); 14 | } 15 | 16 | const worker = this.mediasoupService.getWorker(); 17 | const router = await worker.createRouter({ mediaCodecs }); 18 | const newRoom: IRoom = { 19 | id: roomId, 20 | router: { router }, 21 | peers: new Map(), 22 | }; 23 | this.rooms.set(roomId, newRoom); 24 | 25 | console.log(`>> router created for room ${roomId}`); 26 | return newRoom; 27 | } 28 | 29 | public getRoom(roomId: string): IRoom | undefined { 30 | return this.rooms.get(roomId); 31 | } 32 | 33 | public removeRoom(roomId: string): void { 34 | this.rooms.delete(roomId); 35 | } 36 | 37 | public addPeerToRoom(roomId: string, peerId: string) { 38 | const room = this.rooms.get(roomId); 39 | if (!room) { 40 | throw new Error(`Room ${roomId} not found`); 41 | } 42 | 43 | if (!room.peers.has(peerId)) { 44 | room.peers.set(peerId, { 45 | id: peerId, 46 | transports: new Map(), 47 | producers: new Map(), 48 | consumers: new Map(), 49 | }); 50 | } 51 | } 52 | 53 | public removePeerFromRoom(roomId: string, peerId: string) { 54 | const room = this.rooms.get(roomId); 55 | if (room) { 56 | room.peers.delete(peerId); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /server/src/mediasoup/producer-consumer/producer-consumer.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { RoomService } from '../room/room.service'; 3 | import { IConsumeParams, IProduceParams } from './producer-consumer.interface'; 4 | import { Consumer } from 'mediasoup/node/lib/types'; 5 | 6 | @Injectable() 7 | export class ProducerConsumerService { 8 | constructor(private readonly roomService: RoomService) {} 9 | 10 | public async createProducer(params: IProduceParams): Promise { 11 | const { roomId, peerId, kind, rtpParameters, transportId } = params; 12 | const room = this.roomService.getRoom(roomId); 13 | if (!room) { 14 | throw new Error(`Room ${roomId} not found`); 15 | } 16 | 17 | const peer = room.peers.get(peerId); 18 | if (!peer) { 19 | throw new Error(`Peer ${peerId} not found`); 20 | } 21 | const transportData = peer.transports.get(transportId); 22 | if (!transportData) { 23 | throw new Error('Transport not found'); 24 | } 25 | 26 | const producer = await transportData.transport.produce({ 27 | kind, 28 | rtpParameters, 29 | }); 30 | 31 | peer.producers.set(producer.id, { producer }); 32 | 33 | return producer.id; 34 | } 35 | 36 | public async createConsumer(params: IConsumeParams): Promise { 37 | const { roomId, peerId, producerId, rtpCapabilities, transportId } = params; 38 | const room = this.roomService.getRoom(roomId); 39 | 40 | if (!room) { 41 | throw new Error(`Room ${roomId} not found`); 42 | } 43 | 44 | if (!room.router.router.canConsume({ producerId, rtpCapabilities })) { 45 | throw new Error(`Cannot consume producer ${producerId}`); 46 | } 47 | 48 | const peer = room.peers.get(peerId)!; 49 | 50 | const transportData = peer.transports.get(transportId); 51 | if (!transportData) { 52 | throw new Error('Transport not found'); 53 | } 54 | 55 | const consumer: Consumer = await transportData.transport.consume({ 56 | producerId, 57 | rtpCapabilities, 58 | paused: false, 59 | }); 60 | 61 | peer.consumers.set(consumer.id, { consumer }); 62 | 63 | return { 64 | id: consumer.id, 65 | producerId, 66 | kind: consumer.kind, 67 | rtpParameters: consumer.rtpParameters, 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "project", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/axios": "^3.0.1", 24 | "@nestjs/common": "^10.0.0", 25 | "@nestjs/config": "^3.1.1", 26 | "@nestjs/core": "^10.0.0", 27 | "@nestjs/jwt": "^10.1.1", 28 | "@nestjs/mongoose": "^10.0.1", 29 | "@nestjs/passport": "^10.0.2", 30 | "@nestjs/platform-express": "^10.0.0", 31 | "@nestjs/platform-socket.io": "^10.3.0", 32 | "@nestjs/swagger": "^7.1.11", 33 | "@nestjs/websockets": "^10.3.0", 34 | "axios": "^1.6.5", 35 | "bcrypt": "^5.1.1", 36 | "class-transformer": "^0.5.1", 37 | "class-validator": "^0.14.0", 38 | "cookie-parser": "^1.4.6", 39 | "dotenv": "^16.3.1", 40 | "express-basic-auth": "^1.2.1", 41 | "mediasoup": "^3.13.16", 42 | "mongoose": "^7.5.1", 43 | "nest-winston": "^1.9.4", 44 | "passport": "^0.6.0", 45 | "passport-jwt": "^4.0.1", 46 | "passport-local": "^1.0.0", 47 | "reflect-metadata": "^0.1.13", 48 | "rxjs": "^7.8.1", 49 | "socket.io": "^4.7.3", 50 | "winston": "^3.10.0", 51 | "winston-daily-rotate-file": "^4.7.1" 52 | }, 53 | "devDependencies": { 54 | "@nestjs/cli": "^10.0.0", 55 | "@nestjs/schematics": "^10.0.0", 56 | "@nestjs/testing": "^10.0.0", 57 | "@types/bcrypt": "^5.0.0", 58 | "@types/express": "^4.17.17", 59 | "@types/jest": "^29.5.2", 60 | "@types/node": "^20.3.1", 61 | "@types/passport-jwt": "^3.0.10", 62 | "@types/passport-local": "^1.0.36", 63 | "@types/supertest": "^2.0.12", 64 | "@typescript-eslint/eslint-plugin": "^6.0.0", 65 | "@typescript-eslint/parser": "^6.0.0", 66 | "eslint": "^8.42.0", 67 | "eslint-config-prettier": "^9.0.0", 68 | "eslint-plugin-prettier": "^5.0.0", 69 | "jest": "^29.5.0", 70 | "prettier": "^3.0.0", 71 | "source-map-support": "^0.5.21", 72 | "supertest": "^6.3.3", 73 | "ts-jest": "^29.1.0", 74 | "ts-loader": "^9.4.3", 75 | "ts-node": "^10.9.1", 76 | "tsconfig-paths": "^4.2.0", 77 | "typescript": "^5.1.3" 78 | }, 79 | "jest": { 80 | "moduleFileExtensions": [ 81 | "js", 82 | "json", 83 | "ts" 84 | ], 85 | "rootDir": "src", 86 | "testRegex": ".*\\.spec\\.ts$", 87 | "transform": { 88 | "^.+\\.(t|j)s$": "ts-jest" 89 | }, 90 | "collectCoverageFrom": [ 91 | "**/*.(t|j)s" 92 | ], 93 | "coverageDirectory": "../coverage", 94 | "testEnvironment": "node" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | Nest.js 서버 프로젝트 시작용 보일러 코드입니다. 27 |
28 | 현재까지 적용 : 29 | 기능|상태 30 | |---|:---:| 31 | |auth|✅| 32 | |OAuth|| 33 | |RBAC|✅| 34 | |MongoDB|✅| 35 | |logger|✅| 36 | |typeORM|| 37 | 38 | 39 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 40 |
41 | The implemented functions are shown in the table above 42 | ## Installation 43 | 44 | ```bash 45 | $ npm clone [repository address] 46 | ``` 47 | 48 | ## Running the app 49 | 50 | ```bash 51 | # development 52 | $ npm run start 53 | 54 | # watch mode 55 | $ npm run start:dev 56 | 57 | # production mode 58 | $ npm run start:prod 59 | ``` 60 | 61 | ## Test 62 | 63 | ```bash 64 | # unit tests 65 | $ npm run test 66 | 67 | # e2e tests 68 | $ npm run test:e2e 69 | 70 | # test coverage 71 | $ npm run test:cov 72 | ``` 73 | 74 | ## License 75 | 76 | Nest is [MIT licensed](LICENSE). 77 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser. 13 | 14 | The page will reload when you make changes.\ 15 | You may also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can't go back!** 35 | 36 | If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. 39 | 40 | You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `npm run build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory, Reflector } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { 4 | ClassSerializerInterceptor, 5 | Logger, 6 | ValidationPipe, 7 | } from '@nestjs/common'; 8 | import { NestExpressApplication } from '@nestjs/platform-express'; 9 | import { winstonLogger } from './common/logger/winston.util'; 10 | import * as passport from 'passport'; 11 | import * as cookieParser from 'cookie-parser'; 12 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 13 | import * as expressBasicAuth from 'express-basic-auth'; 14 | 15 | class Application { 16 | private logger = new Logger(Application.name); 17 | private DEV_MODE: boolean; 18 | private PORT: string; 19 | private corsOriginList: string[]; 20 | private ADMIN_USER: string; 21 | private ADMIN_PASSWORD: string; 22 | private Domain: string; 23 | 24 | constructor(private server: NestExpressApplication) { 25 | this.server = server; 26 | 27 | if (!process.env.SECRET_KEY) this.logger.error('Set "SECRET" env'); 28 | this.DEV_MODE = process.env.NODE_ENV === 'production' ? false : true; 29 | this.PORT = process.env.PORT || '5000'; 30 | this.corsOriginList = process.env.CORS_ORIGIN_LIST 31 | ? process.env.CORS_ORIGIN_LIST.split(',').map((origin) => origin.trim()) 32 | : ['*']; 33 | this.ADMIN_USER = process.env.ADMIN_USER || 'username'; 34 | this.ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '1234'; 35 | this.Domain = process.env.DOMAIN || 'http://localhost'; 36 | } 37 | 38 | // docs secure 39 | private setUpBasicAuth() { 40 | this.server.use( 41 | ['/api-docs', '/docs', '/docs-json'], 42 | expressBasicAuth({ 43 | challenge: true, 44 | users: { 45 | [this.ADMIN_USER]: this.ADMIN_PASSWORD, 46 | }, 47 | }), 48 | ); 49 | } 50 | 51 | private setUpOpenAPIMidleware() { 52 | SwaggerModule.setup( 53 | 'api-docs', 54 | this.server, 55 | SwaggerModule.createDocument( 56 | this.server, 57 | new DocumentBuilder() 58 | .setTitle('API DOCS') 59 | .setDescription('nestJS boilerplate') 60 | .setVersion('1.0') 61 | .build(), 62 | ), 63 | ); 64 | } 65 | 66 | private async setUpGlobalMiddleware() { 67 | this.server.enableCors({ 68 | origin: this.corsOriginList, 69 | credentials: true, 70 | }); 71 | this.server.use(cookieParser()); 72 | this.setUpBasicAuth(); 73 | this.setUpOpenAPIMidleware(); 74 | this.server.useGlobalPipes( 75 | new ValidationPipe({ 76 | transform: true, 77 | }), 78 | ); 79 | this.server.use(passport.initialize()); 80 | this.server.useGlobalInterceptors( 81 | new ClassSerializerInterceptor(this.server.get(Reflector)), 82 | ); 83 | } 84 | 85 | async bootstrap() { 86 | await this.setUpGlobalMiddleware(); 87 | await this.server.listen(this.PORT); 88 | } 89 | 90 | startLog() { 91 | if (this.DEV_MODE) { 92 | this.logger.log(`✅ Server on ${this.Domain}:${this.PORT}`); 93 | } else { 94 | this.logger.log(`✅ Server on port ${this.PORT}...`); 95 | } 96 | } 97 | 98 | errorLog(error: string) { 99 | this.logger.error(`🆘 Server error ${error}`); 100 | } 101 | } 102 | 103 | async function init(): Promise { 104 | const server = await NestFactory.create(AppModule, { 105 | logger: winstonLogger, 106 | }); 107 | const app = new Application(server); 108 | await app.bootstrap(); 109 | app.startLog(); 110 | } 111 | 112 | init().catch((error) => { 113 | new Logger('init').error(error); 114 | }); 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mediasoup-tutorial-react-nestjs 2 | 3 | a simple example of mediasoup, an open source library. The front-end server used React and the back-end server used NestJS. Personally, I think the most important part is to think about how to manage the various resources, rooms, and users below. 4 | 5 | 한국어 글(korean): 6 | https://smaivnn.tistory.com/28 7 | 8 | # Screenshot 9 | 10 | mediasoup demo 11 | 12 | # Structure 13 | 14 | ### map resource 15 | 16 | ``` 17 | MediasoupService 18 | ├── Worker[] (workers) 19 | ├── Router[] (routers) 20 | ├── Room[] (rooms) 21 | ├── Peer[] (peers) 22 | ├── Transport[] (transports) 23 | ├── Producer[] (producers) 24 | └── Consumer[] (consumers) 25 | 26 | ``` 27 | 28 | # Resource Description 29 | 30 | These are the various resources needed to use mediasoup. 31 | 32 | ## Worker 33 | 34 | Worker is a central component of mediasoup. It is the lowest-level process of mediasoup and is responsible for media processing. Other resources below are created and **managed in worker**. WebRTC connection work and communication between processes are performed on them. It can be created as many as the number of cpu cores. 35 | 36 | ## Router 37 | 38 | The Router in mediasoup serves as an RTP (Routing and Translation Point). Each Router functions as an SFU (Selective Forwarding Unit), performing tasks such as routing packets received from Producers to Consumers, thereby establishing connections. By default, Producers and Consumers that belong to the same Router can only connect directly with each other. The Router routes RTP packets and connects participants who share the same RTP capabilities. It also manages Transports, Producers, and Consumers. 39 | 40 | It is important to note that a Router does not necessarily correspond to a single room. While it is common to implement one Router per room, multiple rooms can share a single Router, or a single room can utilize multiple Routers. This depends on the design of the application. 41 | 42 | To connect a Producer and a Consumer that are on different Routers, you need to use a PipeTransport to establish a connection between the Routers. This allows media streams to be exchanged between participants who are on different Routers. 43 | 44 | Considerations when connecting between different Routers: 45 | 46 | - **Increased latency :** Since packets must traverse multiple Routers, a slight increase in latency may occur. 47 | 48 | - **Increased complexity in resource management :** Managing multiple Routers and PipeTransports adds complexity to the system. 49 | However, when configured correctly, these impacts can be minimized, and stable connections can be maintained through the use of PipeTransports. 50 | 51 | ## Transport 52 | 53 | Transport is an abstract concept that represents the network connection between the client and the server. It provides a transmission path for media streams and actually handles the encapsulation and transmission of RTP/RTCP packets. For easier understanding, it can be thought of as a "path." Additionally, Transport handles network protocol negotiations such as DTLS and ICE. 54 | 55 | Both the client and the server use **Send** and **Receive** Transports. 56 | 57 | **Client-side Transport** 58 | 59 | - SendTransport: Used when the client sends media to the server. 60 | - RecvTransport: Used when the client receives media from the server. 61 | 62 | **Server-side Transport** 63 | 64 | - The server creates corresponding Transports matching the client. 65 | - It uses WebRtcTransport to handle WebRTC connections with the client. 66 | 67 | **Send/Receive Direction of Transport** 68 | 69 | While Transport itself does not have a send or receive direction, it is practically distinguished according to its role. 70 | 71 | - SendTransport: Mainly used to create Producers to send media. 72 | - RecvTransport: Mainly used to create Consumers to receive media. 73 | 74 | ## Producer 75 | 76 | A Producer is an entity that creates real-time media streams. It can generate audio, video, and data streams, and each stream is treated separately. For example, an audio Producer and a video Producer are managed as separate Producers. 77 | 78 | The Producer sends the client's media stream to the mediasoup server via the SendTransport. It transmits the client's media tracks (MediaStreamTrack) to the server, where they are registered with the server's Router so that other participants can subscribe to them through Consumers. 79 | 80 | ## Consumer 81 | 82 | A Consumer is an entity that receives real-time media streams. It can receive audio, video, and data streams, and it receives media streams from the mediasoup server via the RecvTransport to deliver them to the client. 83 | 84 | The Consumer represents the media stream sent from the server to the client, subscribing to other participants' Producers to receive media. It obtains the Producer's media from the server's Router, delivers it to the client, and the client can play other participants' media through the Consumer. 85 | 86 | # Flow 87 | 88 | ![image](https://github.com/user-attachments/assets/96998b4e-62bc-404c-8d49-601de9ac7354) 89 | 90 | # How to Run 91 | 92 | ## Version 93 | 94 | ### client 95 | 96 | "react": "^18.2.0", 97 | "mediasoup-client": "^3.7.2", 98 | "socket.io-client": "^4.7.3", 99 | 100 | ### server 101 | 102 | "NestJS": "^10.0.0", 103 | "mediasoup": "^3.13.16", 104 | "socket.io": "^4.7.3", 105 | 106 | ## Install 107 | 108 | 1. **Clone the repository:** 109 | 110 | ```bash 111 | git clone https://github.com/smaivnn/mediasoup-tutorial-react-nestjs.git 112 | cd mediasoup-tutorial-react-nestjs 113 | ``` 114 | 115 | 2. **Install dependencies for the client:** 116 | 117 | ``` 118 | cd client 119 | npm install 120 | ``` 121 | 122 | 3. **Install dependencies for the server:** 123 | 124 | ``` 125 | cd ../server 126 | npm install 127 | ``` 128 | 129 | 4. **Run the client (React should be configured to use HTTPS):** 130 | 131 | ``` 132 | cd ../client 133 | npm run start 134 | ``` 135 | 136 | 5. **Run the server:** 137 | 138 | ``` 139 | cd ../server 140 | npm run start:dev 141 | ``` 142 | 143 | 6. **Open your browser and navigate to** https://localhost:3000 144 | 145 | Now you should have both the client and server running, and you can start using the mediasoup example. 146 | 147 | # Additional Resources 148 | 149 | - Mediasoup Homepage: https://mediasoup.org/ 150 | - Mediasoup Forum: https://mediasoup.discourse.group/ 151 | - My blog: https://smaivnn.tistory.com/28 152 | -------------------------------------------------------------------------------- /server/src/signaling/signaling.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConnectedSocket, 3 | MessageBody, 4 | OnGatewayConnection, 5 | OnGatewayDisconnect, 6 | OnGatewayInit, 7 | SubscribeMessage, 8 | WebSocketGateway, 9 | WebSocketServer, 10 | } from '@nestjs/websockets'; 11 | import { Server, Socket } from 'socket.io'; 12 | import { JoinChannelDto } from './dto/join-channel.dto'; 13 | import { RoomService } from 'src/mediasoup/room/room.service'; 14 | import { TransportService } from 'src/mediasoup/transport/transport.service'; 15 | import { ProducerConsumerService } from 'src/mediasoup/producer-consumer/producer-consumer.service'; 16 | 17 | @WebSocketGateway({ 18 | cors: { 19 | origin: 'https://localhost:3000', 20 | credentials: true, 21 | }, 22 | }) 23 | export class SignalingGateway 24 | implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 25 | { 26 | @WebSocketServer() 27 | server: Server; 28 | 29 | constructor( 30 | private readonly roomService: RoomService, 31 | private readonly transportService: TransportService, 32 | private readonly producerConsumerService: ProducerConsumerService, 33 | ) {} 34 | 35 | afterInit() { 36 | console.log(`Server initialized`); 37 | } 38 | 39 | handleConnection(client: Socket) { 40 | console.log(`Client connected: ${client.id}`); 41 | } 42 | 43 | async handleDisconnect(client: Socket) { 44 | console.log(`Client disconnected: ${client.id}`); 45 | } 46 | 47 | @SubscribeMessage('join-room') 48 | async handleJoinChannel( 49 | @MessageBody() dto: JoinChannelDto, 50 | @ConnectedSocket() client: Socket, 51 | ) { 52 | const { roomId, peerId } = dto; 53 | 54 | try { 55 | const newRoom = await this.roomService.createRoom(roomId); 56 | const sendTransportOptions = 57 | await this.transportService.createWebRtcTransport( 58 | roomId, 59 | peerId, 60 | 'send', 61 | ); 62 | 63 | const recvTransportOptions = 64 | await this.transportService.createWebRtcTransport( 65 | roomId, 66 | peerId, 67 | 'recv', 68 | ); 69 | 70 | client.join(roomId); // Socket.io 룸에 참가 71 | 72 | // 방의 현재 참여자 목록 전송 73 | const room = this.roomService.getRoom(roomId); 74 | const peerIds = Array.from(room.peers.keys()); 75 | 76 | // 기존 Producer들의 정보 수집 77 | const existingProducers = []; 78 | for (const [otherPeerId, peer] of room.peers) { 79 | if (otherPeerId !== peerId) { 80 | for (const producer of peer.producers.values()) { 81 | existingProducers.push({ 82 | producerId: producer.producer.id, 83 | peerId: otherPeerId, 84 | kind: producer.producer.kind, 85 | }); 86 | } 87 | } 88 | } 89 | 90 | client.emit('update-peer-list', { peerIds }); 91 | 92 | // 다른 클라이언트들에게 새로운 유저 알림 93 | client.to(roomId).emit('new-peer', { peerId }); 94 | 95 | return { 96 | sendTransportOptions, 97 | recvTransportOptions, 98 | rtpCapabilities: newRoom.router.router.rtpCapabilities, 99 | peerIds, 100 | existingProducers, 101 | }; 102 | } catch (error) { 103 | console.error(error); 104 | client.emit('join-room-error', { error: error.message }); 105 | } 106 | } 107 | 108 | @SubscribeMessage('leave-room') 109 | async handleLeaveRoom(@ConnectedSocket() client: Socket) { 110 | const rooms = Array.from(client.rooms); 111 | 112 | for (const roomId of rooms) { 113 | if (roomId !== client.id) { 114 | const room = this.roomService.getRoom(roomId); 115 | if (room) { 116 | const peer = room.peers.get(client.id); 117 | if (peer) { 118 | // Close all producers 119 | for (const producer of peer.producers.values()) { 120 | producer.producer.close(); 121 | } 122 | // Close all consumers 123 | for (const consumer of peer.consumers.values()) { 124 | consumer.consumer.close(); 125 | } 126 | // Close all transports 127 | for (const transport of peer.transports.values()) { 128 | transport.transport.close(); 129 | } 130 | room.peers.delete(client.id); 131 | } 132 | client.leave(roomId); 133 | client.to(roomId).emit('peer-left', { peerId: client.id }); 134 | if (room.peers.size === 0) { 135 | this.roomService.removeRoom(roomId); 136 | } 137 | } 138 | } 139 | } 140 | return { left: true }; 141 | } 142 | 143 | @SubscribeMessage('connect-transport') 144 | async handleConnectTransport( 145 | @MessageBody() data, 146 | @ConnectedSocket() client: Socket, 147 | ) { 148 | const { roomId, peerId, dtlsParameters, transportId } = data; 149 | const room = this.roomService.getRoom(roomId); 150 | const peer = room?.peers.get(peerId); 151 | if (!peer) { 152 | return { error: 'Peer not found' }; 153 | } 154 | const transportData = peer.transports.get(transportId); 155 | if (!transportData) { 156 | return { error: 'Transport not found' }; 157 | } 158 | await transportData.transport.connect({ dtlsParameters }); 159 | console.log('>> transport connected'); 160 | 161 | return { connected: true }; 162 | } 163 | 164 | @SubscribeMessage('produce') 165 | async handleProduce(@MessageBody() data, @ConnectedSocket() client: Socket) { 166 | const { roomId, peerId, kind, transportId, rtpParameters } = data; 167 | 168 | try { 169 | const producerId = await this.producerConsumerService.createProducer({ 170 | roomId, 171 | peerId, 172 | transportId, 173 | kind, 174 | rtpParameters, 175 | }); 176 | 177 | // 다른 클라이언트에게 새로운 Producer 알림 178 | client.to(roomId).emit('new-producer', { producerId, peerId, kind }); 179 | 180 | return { producerId }; 181 | } catch (error) { 182 | console.error(error); 183 | client.emit('produce-error', { error: error.message }); 184 | } 185 | } 186 | 187 | @SubscribeMessage('consume') 188 | async handleConsume(@MessageBody() data, @ConnectedSocket() client: Socket) { 189 | const { roomId, peerId, producerId, rtpCapabilities, transportId } = data; 190 | try { 191 | const consumerData = await this.producerConsumerService.createConsumer({ 192 | roomId, 193 | peerId, 194 | transportId, 195 | producerId, 196 | rtpCapabilities, 197 | }); 198 | 199 | return { 200 | consumerData, 201 | }; 202 | } catch (error) { 203 | console.error(error); 204 | client.emit('consume-error', { error: error.message }); 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /client/src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import io from "socket.io-client"; 3 | import * as mediasoupClient from "mediasoup-client"; 4 | 5 | const SERVER_URL = "http://localhost:5000"; 6 | 7 | function App() { 8 | const [socket, setSocket] = useState(null); 9 | const [device, setDevice] = useState(null); 10 | const [sendTransport, setSendTransport] = useState(null); 11 | const [recvTransport, setRecvTransport] = useState(null); 12 | const [joined, setJoined] = useState(false); 13 | const [roomId, setRoomId] = useState(""); 14 | const [peers, setPeers] = useState([]); 15 | const [localStream, setLocalStream] = useState(null); 16 | const [videoProducer, setVideoProducer] = useState(null); 17 | const [audioProducer, setAudioProducer] = useState(null); 18 | const [screenProducer, setScreenProducer] = useState(null); 19 | const localVideoRef = useRef(null); 20 | const deviceRef = useRef(null); 21 | const recvTransportRef = useRef(null); 22 | useEffect(() => { 23 | const newSocket = io(SERVER_URL); 24 | setSocket(newSocket); 25 | 26 | newSocket.on("connect", () => { 27 | console.log("Connected to server:", newSocket.id); 28 | }); 29 | 30 | newSocket.on("new-peer", ({ peerId }) => { 31 | setPeers((prevPeers) => [...prevPeers, peerId]); 32 | }); 33 | 34 | newSocket.on("peer-left", ({ peerId }) => { 35 | setPeers((prevPeers) => prevPeers.filter((id) => id !== peerId)); 36 | }); 37 | 38 | return () => { 39 | newSocket.close(); 40 | }; 41 | }, []); 42 | 43 | const createDevice = async (rtpCapabilities) => { 44 | const newDevice = new mediasoupClient.Device(); 45 | await newDevice.load({ routerRtpCapabilities: rtpCapabilities }); 46 | setDevice(newDevice); 47 | deviceRef.current = newDevice; // deviceRef에 값 할당 48 | return newDevice; 49 | }; 50 | 51 | const createSendTransport = (device, transportOptions) => { 52 | console.log(device); 53 | const newSendTransport = device.createSendTransport(transportOptions); 54 | newSendTransport.on("connect", ({ dtlsParameters }, callback, errback) => { 55 | try { 56 | socket.emit("connect-transport", { 57 | transportId: newSendTransport.id, 58 | dtlsParameters, 59 | roomId, 60 | peerId: socket.id, 61 | }); 62 | callback(); 63 | } catch (error) { 64 | errback(error); 65 | } 66 | }); 67 | 68 | newSendTransport.on( 69 | "produce", 70 | ({ kind, rtpParameters }, callback, errback) => { 71 | try { 72 | socket.emit( 73 | "produce", 74 | { 75 | transportId: newSendTransport.id, 76 | kind, 77 | rtpParameters, 78 | roomId, 79 | peerId: socket.id, 80 | }, 81 | (producerId) => { 82 | callback({ id: producerId }); 83 | } 84 | ); 85 | } catch (error) { 86 | errback(error); 87 | } 88 | } 89 | ); 90 | setSendTransport(newSendTransport); 91 | return newSendTransport; 92 | }; 93 | 94 | const createRecvTransport = (device, transportOptions) => { 95 | const newRecvTransport = device.createRecvTransport(transportOptions); 96 | newRecvTransport.on("connect", ({ dtlsParameters }, callback, errback) => { 97 | try { 98 | socket.emit("connect-transport", { 99 | transportId: newRecvTransport.id, 100 | dtlsParameters, 101 | roomId, 102 | peerId: socket.id, 103 | }); 104 | callback(); 105 | } catch (error) { 106 | errback(error); 107 | } 108 | }); 109 | setRecvTransport(newRecvTransport); 110 | recvTransportRef.current = newRecvTransport; 111 | return newRecvTransport; 112 | }; 113 | 114 | const getLocalAudioStreamAndTrack = async () => { 115 | const audioStream = await navigator.mediaDevices.getUserMedia({ 116 | audio: true, 117 | }); 118 | const audioTrack = audioStream.getAudioTracks()[0]; 119 | return audioTrack; 120 | }; 121 | 122 | const joinRoom = () => { 123 | if (!socket || !roomId) return; 124 | 125 | if (window.confirm("방에 참여하시겠습니까?")) { 126 | socket.emit( 127 | "join-room", 128 | { roomId, peerId: socket.id }, 129 | async (response) => { 130 | if (response.error) { 131 | console.error("Error joining room:", response.error); 132 | return; 133 | } 134 | 135 | const { 136 | sendTransportOptions, 137 | recvTransportOptions, 138 | rtpCapabilities, 139 | peerIds, 140 | existingProducers, 141 | } = response; 142 | 143 | // Device 생성 및 로드 144 | const newDevice = await createDevice(rtpCapabilities); 145 | 146 | // 송신용 Transport 생성 147 | const newSendTransport = createSendTransport( 148 | newDevice, 149 | sendTransportOptions 150 | ); 151 | 152 | // 수신용 Transport 생성 153 | const newRecvTransport = createRecvTransport( 154 | newDevice, 155 | recvTransportOptions 156 | ); 157 | 158 | socket.on("new-producer", handleNewProducer); 159 | 160 | // 오디오 스트림 캡처 및 Producer 생성 161 | const audioTrack = await getLocalAudioStreamAndTrack(); 162 | const newAudioProducer = await newSendTransport.produce({ 163 | track: audioTrack, 164 | }); 165 | 166 | setAudioProducer(newAudioProducer); 167 | 168 | // 기존 참여자 목록 업데이트 169 | setPeers(peerIds.filter((id) => id !== socket.id)); 170 | 171 | // 기존 Producer들에 대한 Consumer 생성 172 | for (const producerInfo of existingProducers) { 173 | await consume(producerInfo); 174 | } 175 | 176 | setJoined(true); 177 | } 178 | ); 179 | } 180 | }; 181 | 182 | const leaveRoom = () => { 183 | if (!socket) return; 184 | 185 | socket.emit("leave-room", (response) => { 186 | if (response && response.error) { 187 | console.error("Error leaving room:", response.error); 188 | return; 189 | } 190 | // 로컬 상태 초기화 191 | setJoined(false); 192 | setPeers([]); 193 | // 리소스 정리 194 | if (localStream) { 195 | localStream.getTracks().forEach((track) => track.stop()); 196 | setLocalStream(null); 197 | } 198 | if (sendTransport) { 199 | sendTransport.close(); 200 | setSendTransport(null); 201 | } 202 | if (recvTransport) { 203 | recvTransport.close(); 204 | setRecvTransport(null); 205 | } 206 | if (device) { 207 | setDevice(null); 208 | } 209 | // 이벤트 리스너 제거 210 | socket.off("new-producer", handleNewProducer); 211 | }); 212 | }; 213 | 214 | const startCamera = async () => { 215 | if (!sendTransport) return; 216 | 217 | const stream = await navigator.mediaDevices.getUserMedia({ 218 | video: true, 219 | }); 220 | setLocalStream(stream); 221 | 222 | if (localVideoRef.current) { 223 | localVideoRef.current.srcObject = stream; 224 | } 225 | 226 | const videoTrack = stream.getVideoTracks()[0]; 227 | 228 | // 비디오 Producer 생성 229 | const newVideoProducer = await sendTransport.produce({ track: videoTrack }); 230 | setVideoProducer(newVideoProducer); 231 | }; 232 | 233 | const stopCamera = () => { 234 | if (localStream) { 235 | localStream.getTracks().forEach((track) => track.stop()); 236 | setLocalStream(null); 237 | } 238 | if (localVideoRef.current) { 239 | localVideoRef.current.srcObject = null; 240 | } 241 | if (videoProducer) { 242 | videoProducer.close(); 243 | setVideoProducer(null); 244 | } 245 | if (audioProducer) { 246 | audioProducer.close(); 247 | setAudioProducer(null); 248 | } 249 | }; 250 | 251 | const startScreenShare = async () => { 252 | if (!sendTransport) return; 253 | 254 | const stream = await navigator.mediaDevices.getDisplayMedia({ 255 | video: true, 256 | }); 257 | const screenTrack = stream.getVideoTracks()[0]; 258 | 259 | const newScreenProducer = await sendTransport.produce({ 260 | track: screenTrack, 261 | }); 262 | setScreenProducer(newScreenProducer); 263 | 264 | screenTrack.onended = () => { 265 | stopScreenShare(); 266 | }; 267 | }; 268 | 269 | const stopScreenShare = () => { 270 | if (screenProducer) { 271 | screenProducer.close(); 272 | setScreenProducer(null); 273 | } 274 | }; 275 | 276 | const handleNewProducer = async ({ producerId, peerId, kind }) => { 277 | await consume({ producerId, peerId, kind }); 278 | }; 279 | 280 | const consume = async ({ producerId, peerId, kind }) => { 281 | const device = deviceRef.current; 282 | const recvTransport = recvTransportRef.current; 283 | if (!device || !recvTransport) { 284 | console.log("Device or RecvTransport not initialized"); 285 | } 286 | 287 | socket.emit( 288 | "consume", 289 | { 290 | transportId: recvTransport.id, 291 | producerId, 292 | roomId, 293 | peerId: socket.id, 294 | rtpCapabilities: device.rtpCapabilities, 295 | }, 296 | async (response) => { 297 | if (response.error) { 298 | console.error("Error consuming:", response.error); 299 | return; 300 | } 301 | 302 | const { consumerData } = response; 303 | 304 | const consumer = await recvTransport.consume({ 305 | id: consumerData.id, 306 | producerId: consumerData.producerId, 307 | kind: consumerData.kind, 308 | rtpParameters: consumerData.rtpParameters, 309 | }); 310 | 311 | // Consumer를 resume합니다. 312 | await consumer.resume(); 313 | 314 | // 수신한 미디어를 재생 315 | const remoteStream = new MediaStream(); 316 | remoteStream.addTrack(consumer.track); 317 | 318 | if (consumer.kind === "video") { 319 | const videoElement = document.createElement("video"); 320 | videoElement.srcObject = remoteStream; 321 | videoElement.autoplay = true; 322 | videoElement.playsInline = true; 323 | videoElement.width = 200; 324 | document.getElementById("remote-media").appendChild(videoElement); 325 | } else if (consumer.kind === "audio") { 326 | const audioElement = document.createElement("audio"); 327 | audioElement.srcObject = remoteStream; 328 | audioElement.autoplay = true; 329 | audioElement.controls = true; 330 | document.getElementById("remote-media").appendChild(audioElement); 331 | 332 | // 브라우저의 자동재생 정책을 우회하기 위해 재생 시도 333 | try { 334 | await audioElement.play(); 335 | } catch (err) { 336 | console.error("Audio playback failed:", err); 337 | } 338 | } 339 | } 340 | ); 341 | }; 342 | 343 | return ( 344 |
345 |

Mediasoup Demo

346 |

My Id: {socket ? socket.id : "Not connected"}

347 |

Room: {roomId ? roomId : "-"}

348 | {!joined ? ( 349 |
350 | setRoomId(e.target.value)} 355 | /> 356 | 357 |
358 | ) : ( 359 |
360 | 361 | 364 | 367 |
368 | )} 369 |
370 |

Local Video

371 | 378 |
379 |
380 |

Peers in Room

381 |
    382 | {peers.map((peerId) => ( 383 |
  • {peerId}
  • 384 | ))} 385 |
386 |
387 |
388 |

Remote Media

389 |
390 |
391 |
392 | ); 393 | } 394 | 395 | export default App; 396 | --------------------------------------------------------------------------------