├── .gitignore ├── README.md ├── dockers └── redis │ └── docker-compose.yml ├── docs ├── .gitignore ├── .prettierrc ├── .vscode │ └── settings.json ├── README.md ├── babel.config.js ├── docs │ ├── architecture.md │ ├── examples.md │ ├── getting-started.md │ ├── guides │ │ ├── _category_.json │ │ └── apply-ssl.md │ ├── server-settings.md │ └── tutorials │ │ ├── _category_.json │ │ ├── introduction.md │ │ ├── one-to-one-video-chat.md │ │ ├── video-chat-with-multiple-users-with-sfu.md │ │ └── video-chat-with-multiple-users.mdx ├── docusaurus.config.js ├── package.json ├── sidebars.js ├── src │ ├── components │ │ ├── HomepageFeatures.js │ │ └── HomepageFeatures.module.css │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.js │ │ ├── index.module.css │ │ └── markdown-page.md ├── static │ ├── .nojekyll │ └── img │ │ ├── architecture │ │ ├── create-and-enter-channel.png │ │ ├── create-channel.png │ │ ├── direct-call.png │ │ ├── enter-existing-channel.png │ │ ├── first-user-enters-channel-with-sfu.png │ │ ├── first-user-enters-channel.png │ │ ├── multiple-sfu-connections.png │ │ └── second-user-enters-channel-with-sfu.png │ │ ├── docusaurus.png │ │ ├── favicon.ico │ │ ├── logo.svg │ │ ├── tutorial │ │ ├── docsVersionDropdown.png │ │ ├── initial-ui-2.png │ │ ├── initial-ui.png │ │ ├── local-video.png │ │ ├── localeDropdown.png │ │ ├── meet │ │ │ ├── create.png │ │ │ ├── landing.png │ │ │ ├── meet.png │ │ │ └── preview.png │ │ └── one-to-one-success.png │ │ ├── undraw_docusaurus_mountain.svg │ │ ├── undraw_docusaurus_react.svg │ │ └── undraw_docusaurus_tree.svg └── yarn.lock ├── package.json ├── packages ├── mooyaho-cli │ ├── .gitignore │ ├── index.js │ └── package.json ├── mooyaho-client-sample │ ├── .gitignore │ ├── .prettierrc │ ├── index.html │ ├── one-to-one │ │ ├── index.html │ │ ├── main.ts │ │ └── styles.css │ ├── p2p-channel │ │ ├── index.html │ │ ├── main.ts │ │ └── styles.css │ ├── package.json │ └── sfu-channel │ │ ├── index.html │ │ ├── main.ts │ │ └── styles.css ├── mooyaho-client-sdk │ ├── .gitignore │ ├── .prettierrc │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── Events.ts │ │ ├── Mooyaho.ts │ │ ├── index.ts │ │ └── utils │ │ │ └── waitUntil.ts │ └── tsconfig.json ├── mooyaho-engine │ ├── .env.sample │ ├── .gitignore │ ├── .prettierrc │ ├── .taprc │ ├── mooyaho.config.json │ ├── package.json │ ├── prisma │ │ └── schema.prisma │ ├── src │ │ ├── app.ts │ │ ├── configLoader.ts │ │ ├── lib │ │ │ ├── MooyahoError.ts │ │ │ ├── SFUManager.ts │ │ │ ├── close.ts │ │ │ ├── plugins │ │ │ │ └── protect.ts │ │ │ ├── prisma.ts │ │ │ └── websocket │ │ │ │ ├── Session.ts │ │ │ │ ├── actions │ │ │ │ ├── common.ts │ │ │ │ ├── receive.ts │ │ │ │ └── send.ts │ │ │ │ ├── channelHelper.ts │ │ │ │ ├── redis │ │ │ │ ├── createRedisClient.ts │ │ │ │ ├── prefixer.ts │ │ │ │ └── subscription.ts │ │ │ │ └── rtcHelper.ts │ │ ├── plugins │ │ │ ├── README.md │ │ │ ├── cors.ts │ │ │ ├── sensible.ts │ │ │ ├── support.ts │ │ │ ├── swagger.ts │ │ │ └── websocket.ts │ │ ├── routes │ │ │ ├── README.md │ │ │ ├── channels │ │ │ │ └── index.ts │ │ │ ├── root.ts │ │ │ ├── sessions │ │ │ │ └── index.ts │ │ │ ├── sfu-servers │ │ │ │ └── index.ts │ │ │ └── websocket │ │ │ │ └── index.ts │ │ ├── schema-types │ │ │ ├── channels │ │ │ │ ├── bulk-delete │ │ │ │ │ └── body.d.ts │ │ │ │ ├── create │ │ │ │ │ └── body.d.ts │ │ │ │ ├── get │ │ │ │ │ └── params.d.ts │ │ │ │ └── remove │ │ │ │ │ ├── body.d.ts │ │ │ │ │ └── params.d.ts │ │ │ ├── sessions │ │ │ │ └── integrate │ │ │ │ │ ├── body.d.ts │ │ │ │ │ └── params.d.ts │ │ │ └── sfu-servers │ │ │ │ └── create │ │ │ │ └── body.d.ts │ │ ├── schemas │ │ │ ├── channels │ │ │ │ ├── bulk-delete │ │ │ │ │ └── body.json │ │ │ │ ├── create │ │ │ │ │ └── body.json │ │ │ │ ├── get │ │ │ │ │ └── params.json │ │ │ │ └── remove │ │ │ │ │ └── params.json │ │ │ ├── sessions │ │ │ │ └── integrate │ │ │ │ │ ├── body.json │ │ │ │ │ └── params.json │ │ │ └── sfu-servers │ │ │ │ └── create │ │ │ │ └── body.json │ │ ├── server.ts │ │ ├── services │ │ │ ├── channelService.ts │ │ │ ├── sessionService.ts │ │ │ └── sfuServerService.ts │ │ └── types │ │ │ ├── environment.d.ts │ │ │ └── missing.d.ts │ ├── test │ │ ├── helper.ts │ │ ├── plugins │ │ │ └── support.test.ts │ │ ├── routes │ │ │ ├── example.test.ts │ │ │ └── root.test.ts │ │ └── tsconfig.test.json │ └── tsconfig.json ├── mooyaho-grpc │ ├── .gitignore │ ├── .prettierrc │ ├── package.json │ ├── protos │ │ └── mooyaho.proto │ ├── src │ │ ├── Client.ts │ │ ├── index.ts │ │ └── protos │ │ │ ├── mooyaho.ts │ │ │ └── mooyaho │ │ │ ├── Empty.ts │ │ │ ├── Leave.ts │ │ │ ├── LeaveParams.ts │ │ │ ├── ListenSignalRequest.ts │ │ │ ├── Mooyaho.ts │ │ │ └── Signal.ts │ └── tsconfig.json ├── mooyaho-server-sdk │ ├── .gitignore │ ├── .prettierrc │ ├── package.json │ ├── src │ │ ├── Mooyaho.ts │ │ ├── index.ts │ │ └── types.ts │ └── tsconfig.json ├── mooyaho-sfu │ ├── .env │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── package.json │ ├── src │ │ ├── channel │ │ │ ├── Channel.ts │ │ │ ├── ChannelManager.ts │ │ │ ├── Connection.ts │ │ │ └── ConnectionManager.ts │ │ ├── getDispatchSignal.ts │ │ ├── index.d.ts │ │ └── main.ts │ └── tsconfig.json └── webrtc-sample │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── index.js │ ├── package.json │ ├── sfu.html │ ├── sfu.js │ ├── snowpack.config.js │ ├── style.css │ └── yarn.lock └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .parcel-cache 2 | node_modules 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Mooyaho 2 | 3 | Mooyaho is a server that makes WebRTC super easy. 4 | 5 | > Mooyaho is a temporary name; it might be changed later on. 6 | -------------------------------------------------------------------------------- /dockers/redis/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | redis: 4 | image: redis 5 | command: redis-server --port 6379 6 | container_name: redis 7 | hostname: redis 8 | labels: 9 | - "name=redis" 10 | - "mode=standalone" 11 | ports: 12 | - 6379:6379 13 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /docs/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": false 3 | } -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ## Installation 6 | 7 | ```console 8 | yarn install 9 | ``` 10 | 11 | ## Local Development 12 | 13 | ```console 14 | yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ## Build 20 | 21 | ```console 22 | yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ## Deployment 28 | 29 | ```console 30 | GIT_USER= USE_SSH=true yarn deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/docs/architecture.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Architecture 6 | 7 | This document will describe the workflow of Mooyaho. Reading this document helps you to understand how the whole system works. It is recommended to read before you start development. 8 | 9 | Reading this document is not required to use Mooyaho, it just helps you with understanding how the whole system works. 10 | 11 | If you are using Mooyaho, you have to prepare following servers: 12 | 13 | 1. **Mooyaho Server**: This server will manage the sessions and the channels. This server provides REST API and WebSocket API. This server can be scaled up to multiple instances if you use a load balancer (Using AWS ElasticLoadbalancer or Nginx is recommended). 14 | 2. **Redis Server**: Mooyaho server uses a Redis server for channel subscription. 15 | 3. **SFU Server**: Using this server is optional. This server is used when you want to apply [SFU](https://webrtcglossary.com/sfu/) to the channel. You can have multiple SFU Servers. You can manage the SFU server using the [REST API](/). 16 | 4. **Service Server**: This is the server that you implement for your service. You can create a channel or integrate user authentication with Mooyaho sessions. If you do not have a server for your service, you can still use Mooyaho with `allowAnonymous` mode (this is INSECURE). 17 | 18 | ## Scenario 1: Many to Many video chat 19 | 20 | This section illustrates how many to many video chat works with Mooyaho. 21 | 22 | ### 1. User creates a channel 23 | 24 | ![User creates a channel](/img/architecture/create-channel.png) 25 | 26 | 1. User (Client A) creates a channel by calling a REST API of your own service server that you need to implement. 27 | 2. Service Server creates a channel by using `createChannel` API of Mooyaho Server SDK. 28 | 3. Mooyaho Server creates a channel and generates a unique Channel ID and responds to the Service Server. 29 | 4. Service Server now responds to the user and passes the Channel ID. 30 | 31 | ### 2. User enters the Channel 32 | 33 | ![User enters the channel](/img/architecture/first-user-enters-channel.png) 34 | 35 | 1. User (Client A) connects to the Mooyaho Server. 36 | 2. Mooyaho Server generates a unique Session ID, and passes to the user. 37 | 3. User integrates thes Session ID with the current User information by calling a REST API of your own service server that you need to implement. 38 | 4. Service Server integrates User information with the Session by using `integrateUser` API of Mooyaho Server SDK. 39 | 5. Finally, the user enters the channel by using `enter` API of Client SDK. 40 | 41 | ### 3. Second user enters the Channel 42 | 43 | ![User creates & enters the channel](/img/architecture/enter-existing-channel.png) 44 | 45 | Suppose a user (Client A) has already entered to a Mooyaho channel. 46 | 47 | 1. User (Client B) connects to the Mooyaho Server. 48 | 2. Mooyaho Server generates a unique Session ID, and passes to the user. 49 | 3. User integrates the Session ID with the current User information by calling a REST API of your own service server that you need to implement. 50 | 4. Service Server integrates User information with the Session by using `integrateUser` API of Mooyaho Server SDK. 51 | 5. User enters the channel by using `enter` API of Client SDK. 52 | 6. Connection between Client A and Client B is establishes. At this time, existing user (Client A) **offer**s and newly connected user **answer**s the WebRTC signal (For mor details about offer/answer, read [Designing the signaling protocol](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling#designing_the_signaling_protocol)). Mooyaho Client SDK handles all the offer, answer, and ICE Candidate signals internally. 53 | 54 | If another user (e.g. Client C) enters the channel, it takes the same process. Client A and Client B will offer the WebRTC signal to Client C, and Client C will answer the offer. 55 | 56 | ## Scenario 2: Many to Many Video Chat (with SFU Server Enabled) 57 | 58 | This section illustrates how Many to Many video chat works with Mooyaho with SFU Server enabled. 59 | 60 | Channel creating process is similar to the scenario 1. You can create a channel with SFU Server enabled by calling `createChannel(true)`. This parameter represents `isSFU` flag. When Mooyaho server creates a channel with SFU Server enabled, channel will be binded to the SFU Server where least users are connected. 61 | 62 | ### 1. User enters the Channel 63 | 64 | ![User creates & enters the channel](/img/architecture/first-user-enters-channel-with-sfu.png) 65 | 66 | 1. User (Client A) connects to the Mooyaho Server. 67 | 2. Mooyaho Server generates a unique Session ID, and passes to the user. 68 | 3. User integrates the Session ID with the current User information by calling a REST API of your own service server that you need to implement. 69 | 4. Service Server integrates User information with the Session by using `integrateUser` API of Mooyaho Server SDK. 70 | 5. User enters the channel by using `enter` API of Client SDK. When user enters the channel, Mooyaho server will respond to the user that this channel is SFU enabled. 71 | 6. Connection between the User and SFU Server establishes. In this process, the user will send the offer WebRTC signal to the SFU Server. SFU server will answer the signal. This WebRTC connection is unidirectional; user sends his/her media stream to the SFU server. SFU server won't send and media stream to the user in this connection. Mooyaho Client SDK handles all the offer, answer, and ICE Candidate signals internally. 72 | 73 | ### 2. Second user enters the Channel 74 | 75 | ![Second user enters the Channel](/img/architecture/second-user-enters-channel-with-sfu.png) 76 | 77 | Suppose a user (Client A) has already entered to a Mooyaho channel. 78 | 79 | 1. User (Client B) connects to the Mooyaho Server. 80 | 2. Mooyaho Server generates a unique Session ID, and passes to the user. 81 | 3. User integrates the Session ID with the current User information by calling a REST API of your own service server that you need to implement. 82 | 4. Service Server integrates User information with the Session by using `integrateUser` API of Mooyaho Server SDK. 83 | 5. User enters the channel by using `enter` API of Client SDK. 84 | 6. Connection between Client B and SFU Server establishes. Just as previous section, stream of this WebRTC connection is unidirectional. User sends his/her media stream to the SFU server. 85 | 7. After the WebRTC connection between Client B and SFU Server establishes, the SFU Server will send the offer WebRTC signal to each of the users. In this WebRTC connection, the SFU Server will send the media stream of the other user. 86 | 87 | If another user (Client C) connects to the channel, the WebRTC connection will be established as below. 88 | 89 | ![Multiple SFU User](/img/architecture/multiple-sfu-connections.png) 90 | 91 | SFU Server will forward the user's media stream to the other user. If there are N users, a user will send 1 media stream to the SFU Server, and user will recevie N - 1 media streams from the SFU Server. 92 | 93 | ## Scenario 3: One to One video chat 94 | 95 | Compared to Many to Many video chat, One to One video chat does not require a channel to be created. 96 | 97 | When you want to implement One to One video chat, you have to set `allowDirectCall` field to `true` in your config of Mooyaho Client SDK. Furthermore, you do not have to integrate user information when you implement one to one video chat. 98 | 99 | #### Example 100 | 101 | ```javascript 102 | const config = { 103 | url: 'ws://localhost:8081', 104 | allowDirectCall: true, 105 | } 106 | 107 | const mooyaho = new Mooyaho(config) 108 | ``` 109 | 110 | ![Direct call](/img/architecture/direct-call.png) 111 | 112 | 1. User (Client A) connects to the Mooyaho Server. 113 | 2. Mooyaho Server generates a unique Session ID, and passes to the user. 114 | 3. User has to pass the Session ID to the Service Server. The Service Server should some how pass this Session ID to the another user (Client B). You have to implement this by a REST API or WebSocket yourself. 115 | 4. Another user (Client B) connects to the Mooyaho Server. Then, this user calls the `directCall` API of Mooyaho Client SDK. The parameter of this API should be the Session ID of the user to call (Client A). 116 | 5. WebRTC connection between Client A and Client B establishes. 117 | -------------------------------------------------------------------------------- /docs/docs/examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # Examples 6 | -------------------------------------------------------------------------------- /docs/docs/guides/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Guides", 3 | "position": 6 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/guides/apply-ssl.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | To use Mooyaho server from HTTPS address, you should apply SSL to your Mooyaho server in order to use `wss://` protocol. This document shows how to apply SSL with letsencrypt. 6 | -------------------------------------------------------------------------------- /docs/docs/server-settings.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # Server Settings 6 | -------------------------------------------------------------------------------- /docs/docs/tutorials/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Tutorials", 3 | "position": 2 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/tutorials/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Introduction 6 | 7 | This tutorial teaches you how to create video chat app using Mooyaho. To minimize the amount of code you have to write, we will use React to implement the user interface. For server implementation, we will use Node.js and Fastify. 8 | -------------------------------------------------------------------------------- /docs/docs/tutorials/one-to-one-video-chat.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # 1:1 Video Chat 6 | 7 | ## Introduction 8 | 9 | We will implement 1:1 video chat with Mooyaho in this tutorial. In this part, we will skip the server implementation. We will implement the feature by directly entering the Session ID. 10 | 11 | When user enters the webpage, a Session ID will be issued to the user. User can share this Session ID to whom he/she wants to chat. 12 | 13 | Before starting this tutorial, you should prepare a running Mooyaho Engine server. If your Mooyaho Engine server is not ready, please check [Setup server](http://localhost:3000/docs/getting-started#setup-server). 14 | 15 | ## Create a web project with Snowpack 16 | 17 | In this section, we will create a web project using [Snowpack](https://www.snowpack.dev/). Snowpack is a frontend build tool designed for the modern web. You can use other tools if you want (such as Parcel, Webpack, etc). We chose Snowpack because it is simple and fast. 18 | 19 | ```bash 20 | # create a directory 21 | mkdir one-to-one-sample 22 | cd one-to-one-sample 23 | yarn init -y # or npm init -y 24 | yarn add --dev snowpack # or npm install --save-dev snowpack 25 | ``` 26 | 27 | Then, add following scripts to your `package.json` file. 28 | 29 | ```json 30 | "scripts": { 31 | "start": "snowpack dev", 32 | "build": "snowpack build" 33 | } 34 | ``` 35 | 36 | Default port of snowpack dev server is 8080, which collides with Mooyaho Engine server. You should change the port of one of those servers. We will change the port of snowpack dev server to 8081. 37 | 38 | To do this, you have to initialize snowpack config by following command. 39 | 40 | ``` 41 | yarn snowpack init # or npx snowpack init 42 | ``` 43 | 44 | This command will create `snowpack.config.js` file in your project directory. Open this file and add set `devOptions.port` to `8081`. 45 | 46 | You can now run your dev server with `yarn start` or `npm start`. 47 | 48 | ## Initialize html, js, css files 49 | 50 | First, create a index.js file in project root directory. 51 | 52 | `index.js` 53 | 54 | ```javascript 55 | console.log('Hello World') 56 | ``` 57 | 58 | Then, create a empty `style.css` file in project root directory. 59 | 60 | Finally, create `index.html` and copy the code below. 61 | 62 | `index.html` 63 | 64 | ```html 65 | 66 | 67 | 68 | 69 | 70 | 71 | Document 72 | 73 | 74 | 75 | 76 | 77 | 78 | ``` 79 | 80 | > Don't forget to set `type="module"` attribute to `index.js` file. You need this attribute to use `import` statement in your js file. 81 | 82 | ## Install Mooyaho Client SDK 83 | 84 | Install Mooyaho Client SDK to integrate Mooyaho Engine in your web app. 85 | 86 | ```bash 87 | yarn add @mooyaho/browser # or npm install @mooyaho/browser 88 | ``` 89 | 90 | Now, you are ready to use Mooyaho Engine. 91 | 92 | ## Initialize UI for 1:1 Video Chat 93 | 94 | Let's first implment UI for 1:1 video chat. In upper section, we will create a user interface where user can get their own Session ID or enter other user's Session ID. 95 | 96 | Modify the body tag of `index.html` as below. 97 | 98 | ```html 99 | 100 |
101 | My Session ID: 102 | 103 |
104 |
105 | Enter Session ID to call: 106 | 107 | 108 |
109 |
Ready
110 | 111 | 112 | 113 | ``` 114 | 115 | ![](/img/tutorial/initial-ui.png) 116 | 117 | Then, we will show two video tags on your screen. One for your own video and another for other user's video. Add the following code below the hang up button tag. 118 | 119 | `index.html` 120 | 121 | ```html 122 |
123 | 124 | 125 |
126 | ``` 127 | 128 | And here is the style code for these video tags. Edit `style.css` as below. 129 | 130 | `style.css` 131 | 132 | ```css 133 | .videos { 134 | display: flex; 135 | align-items: flex-end; 136 | } 137 | 138 | .videos video { 139 | background: black; 140 | } 141 | 142 | #local-video { 143 | width: 128px; 144 | height: 128px; 145 | } 146 | 147 | #remote-video { 148 | margin-left: 16px; 149 | width: 320px; 150 | height: 320px; 151 | } 152 | ``` 153 | 154 | ![](/img/tutorial/initial-ui-2.png) 155 | 156 | ## Initialize Mooyaho Instance and get Session ID issued. 157 | 158 | Now, we will import Mooyaho Client SDK and initialize an instance. 159 | 160 | `index.js` 161 | 162 | ```js 163 | import Mooyaho from 'mooyaho-client-sdk' 164 | 165 | const mooyaho = new Mooyaho({ 166 | url: 'ws://localhost:8080', 167 | allowDirectCall: true, 168 | }) 169 | ``` 170 | 171 | Then, we will connect to the server using `connect` method. This method returns a Promise that resolves Session ID. After getting Session ID, we will set the value at `#my-session-id` span element. 172 | 173 | Write the below in `index.js` 174 | 175 | ```js 176 | mooyaho.connect().then((sessionId) => { 177 | const mySessionIdDiv = document.getElementById('my-session-id') 178 | mySessionIdDiv.innerHTML = sessionId 179 | }) 180 | ``` 181 | 182 | Now check your page on your browser, then you will see the current Session ID on the top of the page. 183 | 184 | ## Get user media and show it on screen. 185 | 186 | This time, we will get user's audio and video stream from browser and show it on our video tag that we've created. When we show the user's own video stream we have to set it muted or else audio howling feedback will occur. 187 | 188 | Write the code below in `index.js`. 189 | 190 | ```js 191 | mooyaho 192 | .createUserMedia({ 193 | audio: true, 194 | video: true, 195 | }) 196 | .then((stream) => { 197 | // add video tag to body and set stream 198 | const localVideo = document.getElementById('local-video') 199 | localVideo.muted = true 200 | localVideo.srcObject = stream 201 | }) 202 | ``` 203 | 204 | Now, the browser will request permission for user's stream, and video will be shown on the `#local-video` element. 205 | 206 | ![](/img/tutorial/local-video.png) 207 | 208 | ## Call other user by entering Session ID 209 | 210 | Let's call other user by entering their Session ID on the form. We will register event handler for `submit` event on the `#call-form` element. 211 | 212 | In this handler, we will call `directCall` method of the Mooyaho Client SDK instance. 213 | 214 | `index.html` 215 | 216 | ```js 217 | const callForm = document.getElementById('call-form') 218 | 219 | callForm.addEventListener('submit', (e) => { 220 | e.preventDefault() // prevents refreshing page 221 | const sessionId = callForm.querySelector('input').value 222 | mooyaho.directCall(sessionId) 223 | }) 224 | ``` 225 | 226 | And this is it! Mooyaho will do all the work for the connection establishment. You can get notified when peer connection is successfully established by registering a event handler with mooyaho. 227 | 228 | ```js 229 | mooyaho.addEventListener('peerConnected', (event) => { 230 | console.log('Peer is now connected', event) 231 | }) 232 | ``` 233 | 234 | Now, you just need to show the peer's stream on your screen. 235 | 236 | ## Show remote peer's stream 237 | 238 | To show remote peer's stream, you have to handle `remoteStreamChanged` of `mooyaho` instance. Write the code below in `index.js` to do so. 239 | 240 | ```javascript 241 | mooyaho.addEventListener('remoteStreamChanged', (event) => { 242 | const remoteVideo = document.getElementById('remote-video') 243 | remoteVideo.srcObject = mooyaho.getRemoteStreamById(event.sessionId) 244 | }) 245 | ``` 246 | 247 | Now, open a new window in your browser and enter the Session ID of the other page to check the code is working. 248 | 249 | > Before you test this, it is recommended to mute the system volume because this will make audio howling feedback. 250 | 251 | ![](/img/tutorial/one-to-one-success.png) 252 | 253 | In `peerConnected` or `remoteStreamChanged` handler, you can acces the `RTCPeerConnection` instance of the peer. So, you can do anything you want with it. 254 | 255 | Mooyaho's `directCall` feature is not restricted to to single connection. You can call multiple users by passing Session ID to `directCall` method. 256 | 257 | To get the full code of this project, check this [GitHub Repo](https://github.com/) 258 | 259 | ## Conclusion 260 | 261 | You have successfully implemented one to one video chat with Mooyaho! If you want to implement 262 | video call feature with multiple users, check out the next tutorial. 263 | -------------------------------------------------------------------------------- /docs/docs/tutorials/video-chat-with-multiple-users-with-sfu.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # Video Chat with Multiple Users (with SFU) 6 | -------------------------------------------------------------------------------- /docs/docs/tutorials/video-chat-with-multiple-users.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | import Tabs from '@theme/Tabs' 6 | import TabItem from '@theme/TabItem' 7 | 8 | # Video Chat with Multiple Users 9 | 10 | ## Introduction 11 | 12 | In this tutorial, we will create **Mooyaho Meet**, which is a video chat webapp similar to [Google Meet](https://meet.google.com/). In this app, multiple users can have conversations with each other. 13 | 14 | ![](/img/tutorial/meet/preview.png) 15 | 16 | Since there are many UI to prepare, we will use [React](https://reactjs.org/) to build the app to make the development easier. 17 | 18 | Furthermore, we also have to implement a simple API server to manage channels and user sessions. We will use [Node.js](https://nodejs.org/) and [Express](https://expressjs.com/) to build the server. 19 | 20 | Source code of this tutorial is available on [GitHub](https://github.com/mooyaho-webrtc) 21 | 22 | 23 | 24 | ## Create Mooyaho Meet Server 25 | 26 | ### Initialize Project 27 | 28 | Let's first prepare a server to manage Mooyaho channels and sessions. 29 | 30 | Create a project directory and initialize a Node.js project. Then, install express to create a web server. 31 | 32 | ```bash 33 | $ mkdir mooyaho-meet-server 34 | $ cd mooyaho-meet-server 35 | $ yarn init -y # or npm init -y 36 | $ yarn add express # or npm install express 37 | ``` 38 | 39 | ### Enable ESM in Node.js 40 | 41 | We have to enable [ESM](https://nodejs.org/docs/latest-v14.x/api/esm.html) module system in our project because one of our dependency of the sample project (lowdb) is only compatible with ESM. 42 | 43 | > Mooyaho itself is compatible with both CommonJS and ESM. `lowdb` is only used in this sample project. 44 | 45 | Add `"type": "module"` to `package.json` file. 46 | 47 | Now, ESM is enabled in our project. You can use `import` instead of `require` to import modules. 48 | 49 | After installation, create `main.js` file in your project directory, and enter the following code. 50 | 51 | ```js 52 | import express from 'express' 53 | 54 | const app = express() 55 | 56 | app.get('/', (req, res) => { 57 | res.send('Hello World') 58 | }) 59 | 60 | app.listen(5000, () => { 61 | console.log('Server is running on port 5000') 62 | }) 63 | ``` 64 | 65 | Then, enter the following command to start the server. 66 | 67 | ```bash 68 | node ./main.js 69 | ``` 70 | 71 | Check [http://localhost:5000/](http://localhost:5000/) from your browser to see your server is running. 72 | 73 | In this server project, we will create 3 API endpoints. 74 | 75 | **`main.js`** 76 | 77 | ```js {5-13} 78 | const express = require('express') 79 | 80 | const app = express() 81 | 82 | app.get('/auth/integrate', (req, res) => { 83 | // integrates user session information 84 | }) 85 | app.post('/meet', (req, res) => { 86 | // creates meet channel 87 | }) 88 | app.get('/meet/:code', (req, res) => { 89 | // retrieve meet channel information 90 | }) 91 | 92 | app.listen(5000, () => { 93 | console.log('Server is running on port 5000') 94 | }) 95 | ``` 96 | 97 | ### Initialize Mooyaho Engine 98 | 99 | In this section, we will initialize Mooyaho Engine, and integrate our server to it. 100 | 101 | To initialize mooyaho engine, enter following command from outside of current server project directory. 102 | 103 | ```bash 104 | npx mooyaho-cli init engine 105 | ``` 106 | 107 | After initializing, run the Mooyaho Engine. 108 | 109 | ```bash 110 | cd mooyaho-engine 111 | yarn start 112 | ``` 113 | 114 | ### Connect to Mooyaho Engine from Server 115 | 116 | Now, we will create a Mooyaho client instance from our API server. By using this client instance, you can manage the channels and sessions via its methods. To do this, install `@mooyaho/server-sdk` from the server. 117 | 118 | 125 | 126 | 127 | ```bash 128 | yarn add @mooyaho/server-sdk 129 | ``` 130 | 131 | 132 | 133 | 134 | ```bash 135 | npm install @mooyaho/server-sdk 136 | ``` 137 | 138 | 139 | 140 | 141 | Then, import the package and create the instance as following. 142 | 143 | ```js {1,5-8} 144 | import Mooyaho from 'mooyaho'; 145 | const express = require('express') 146 | 147 | const app = express() 148 | const mooyaho = new Mooyaho(' 149 | http://localhost:8080', 150 | '******' 151 | ) 152 | 153 | app.get('/auth/integrate', (req, res) => { 154 | // integrates user session information 155 | }) 156 | app.post('/meet', (req, res) => { 157 | // creates meet channel 158 | }) 159 | app.get('/meet/:code', (req, res) => { 160 | // retrieve meet channel information 161 | }) 162 | 163 | app.listen(5000, () => { 164 | console.log('Server is running on port 5000') 165 | }) 166 | ``` 167 | 168 | The second parameter of `Mooyaho` class is the API Server secret key. To retrieve the secret key, check `API_KEY` field of the `.env` file from the Mooyaho Engine directory. This key is generated automatically when you have initialized your Mooyaho Engine. 169 | 170 | 171 | ### Setup database 172 | 173 | We need a database to store channel information. For the database, we will use lowdb which is lightweight embedded database. In real project, you should use a database like MongoDB or MariaDB since this database is only suitable for small projects. 174 | 175 | Install lowdb to your project. 176 | 177 | 184 | 185 | 186 | ```bash 187 | yarn add lowdb 188 | ``` 189 | 190 | 191 | 192 | 193 | ```bash 194 | npm install lowdb 195 | ``` 196 | 197 | 198 | 199 | 200 | Next, create db.js file in your server project directory and write the code as below. 201 | 202 | ```js 203 | import { Low, JSONFile } from 'lowdb' 204 | import path from 'path' 205 | import { fileURLToPath } from 'url' 206 | 207 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) // way to retrieve __dirname in ESM 208 | const file = path.resolve(__dirname, './db.json') 209 | const adapater = new JSONFile(file) 210 | const db = new Low(adapater) 211 | 212 | export default db 213 | ``` 214 | 215 | We've separated the database setup code to another file to make the server code simple possible. 216 | 217 | Now, we are ready to implement the Server API. -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | const lightCodeTheme = require('prism-react-renderer/themes/github') 2 | const darkCodeTheme = require('prism-react-renderer/themes/dracula') 3 | 4 | /** @type {import('@docusaurus/types').DocusaurusConfig} */ 5 | module.exports = { 6 | title: 'Mooyaho', 7 | tagline: 'Dinosaurs are cool', 8 | url: 'https://your-docusaurus-test-site.com', 9 | baseUrl: '/', 10 | onBrokenLinks: 'throw', 11 | onBrokenMarkdownLinks: 'warn', 12 | favicon: 'img/favicon.ico', 13 | organizationName: 'facebook', // Usually your GitHub org/user name. 14 | projectName: 'docusaurus', // Usually your repo name. 15 | themeConfig: { 16 | navbar: { 17 | title: 'Mooyaho', 18 | logo: { 19 | alt: 'Mooyaho Logo', 20 | src: 'img/logo.svg', 21 | }, 22 | items: [ 23 | { 24 | to: '/docs/getting-started', 25 | position: 'left', 26 | label: 'Getting Started', 27 | }, 28 | // { to: '/blog', label: 'Blog', position: 'left' }, 29 | { 30 | href: 'https://github.com/facebook/docusaurus', 31 | label: 'GitHub', 32 | position: 'right', 33 | }, 34 | ], 35 | }, 36 | footer: { 37 | style: 'dark', 38 | links: [ 39 | { 40 | title: 'Docs', 41 | items: [ 42 | { 43 | label: 'Tutorial', 44 | to: '/docs/intro', 45 | }, 46 | ], 47 | }, 48 | { 49 | title: 'Community', 50 | items: [ 51 | { 52 | label: 'Stack Overflow', 53 | href: 'https://stackoverflow.com/questions/tagged/docusaurus', 54 | }, 55 | { 56 | label: 'Discord', 57 | href: 'https://discordapp.com/invite/docusaurus', 58 | }, 59 | { 60 | label: 'Twitter', 61 | href: 'https://twitter.com/docusaurus', 62 | }, 63 | ], 64 | }, 65 | { 66 | title: 'More', 67 | items: [ 68 | { 69 | label: 'Blog', 70 | to: '/blog', 71 | }, 72 | { 73 | label: 'GitHub', 74 | href: 'https://github.com/facebook/docusaurus', 75 | }, 76 | ], 77 | }, 78 | ], 79 | copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`, 80 | }, 81 | prism: { 82 | theme: lightCodeTheme, 83 | darkTheme: darkCodeTheme, 84 | }, 85 | }, 86 | presets: [ 87 | [ 88 | '@docusaurus/preset-classic', 89 | { 90 | docs: { 91 | sidebarPath: require.resolve('./sidebars.js'), 92 | // Please change this to your repo. 93 | editUrl: 94 | 'https://github.com/facebook/docusaurus/edit/master/website/', 95 | }, 96 | blog: { 97 | showReadingTime: true, 98 | // Please change this to your repo. 99 | editUrl: 100 | 'https://github.com/facebook/docusaurus/edit/master/website/blog/', 101 | }, 102 | theme: { 103 | customCss: require.resolve('./src/css/custom.css'), 104 | }, 105 | }, 106 | ], 107 | ], 108 | } 109 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@docusaurus/core": "2.0.0-beta.4", 18 | "@docusaurus/preset-classic": "2.0.0-beta.4", 19 | "@mdx-js/react": "^1.6.21", 20 | "@svgr/webpack": "^5.5.0", 21 | "clsx": "^1.1.1", 22 | "file-loader": "^6.2.0", 23 | "prism-react-renderer": "^1.2.1", 24 | "react": "^17.0.1", 25 | "react-dom": "^17.0.1", 26 | "url-loader": "^4.1.1" 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.5%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | } 40 | } -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | module.exports = { 13 | // By default, Docusaurus generates a sidebar from the docs folder structure 14 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 15 | 16 | // But you can create a sidebar manually 17 | /* 18 | tutorialSidebar: [ 19 | { 20 | type: 'category', 21 | label: 'Tutorial', 22 | items: ['hello'], 23 | }, 24 | ], 25 | */ 26 | }; 27 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import styles from './HomepageFeatures.module.css'; 4 | 5 | const FeatureList = [ 6 | { 7 | title: 'Easy to Use', 8 | Svg: require('../../static/img/undraw_docusaurus_mountain.svg').default, 9 | description: ( 10 | <> 11 | Docusaurus was designed from the ground up to be easily installed and 12 | used to get your website up and running quickly. 13 | 14 | ), 15 | }, 16 | { 17 | title: 'Focus on What Matters', 18 | Svg: require('../../static/img/undraw_docusaurus_tree.svg').default, 19 | description: ( 20 | <> 21 | Docusaurus lets you focus on your docs, and we'll do the chores. Go 22 | ahead and move your docs into the docs directory. 23 | 24 | ), 25 | }, 26 | { 27 | title: 'Powered by React', 28 | Svg: require('../../static/img/undraw_docusaurus_react.svg').default, 29 | description: ( 30 | <> 31 | Extend or customize your website layout by reusing React. Docusaurus can 32 | be extended while reusing the same header and footer. 33 | 34 | ), 35 | }, 36 | ]; 37 | 38 | function Feature({Svg, title, description}) { 39 | return ( 40 |
41 |
42 | 43 |
44 |
45 |

{title}

46 |

{description}

47 |
48 |
49 | ); 50 | } 51 | 52 | export default function HomepageFeatures() { 53 | return ( 54 |
55 |
56 |
57 | {FeatureList.map((props, idx) => ( 58 | 59 | ))} 60 |
61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | .features { 4 | display: flex; 5 | align-items: center; 6 | padding: 2rem 0; 7 | width: 100%; 8 | } 9 | 10 | .featureSvg { 11 | height: 200px; 12 | width: 200px; 13 | } 14 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | :root { 10 | --ifm-color-primary: #25c2a0; 11 | --ifm-color-primary-dark: rgb(33, 175, 144); 12 | --ifm-color-primary-darker: rgb(31, 165, 136); 13 | --ifm-color-primary-darkest: rgb(26, 136, 112); 14 | --ifm-color-primary-light: rgb(70, 203, 174); 15 | --ifm-color-primary-lighter: rgb(102, 212, 189); 16 | --ifm-color-primary-lightest: rgb(146, 224, 208); 17 | --ifm-code-font-size: 95%; 18 | } 19 | 20 | .docusaurus-highlight-code-line { 21 | background-color: rgba(0, 0, 0, 0.1); 22 | display: block; 23 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 24 | padding: 0 var(--ifm-pre-padding); 25 | } 26 | 27 | html[data-theme='dark'] .docusaurus-highlight-code-line { 28 | background-color: rgba(0, 0, 0, 0.3); 29 | } 30 | -------------------------------------------------------------------------------- /docs/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import Layout from '@theme/Layout'; 4 | import Link from '@docusaurus/Link'; 5 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 6 | import styles from './index.module.css'; 7 | import HomepageFeatures from '../components/HomepageFeatures'; 8 | 9 | function HomepageHeader() { 10 | const {siteConfig} = useDocusaurusContext(); 11 | return ( 12 |
13 |
14 |

{siteConfig.title}

15 |

{siteConfig.tagline}

16 |
17 | 20 | Docusaurus Tutorial - 5min ⏱️ 21 | 22 |
23 |
24 |
25 | ); 26 | } 27 | 28 | export default function Home() { 29 | const {siteConfig} = useDocusaurusContext(); 30 | return ( 31 | 34 | 35 |
36 | 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | /** 4 | * CSS files with the .module.css suffix will be treated as CSS modules 5 | * and scoped locally. 6 | */ 7 | 8 | .heroBanner { 9 | padding: 4rem 0; 10 | text-align: center; 11 | position: relative; 12 | overflow: hidden; 13 | } 14 | 15 | @media screen and (max-width: 966px) { 16 | .heroBanner { 17 | padding: 2rem; 18 | } 19 | } 20 | 21 | .buttons { 22 | display: flex; 23 | align-items: center; 24 | justify-content: center; 25 | } 26 | -------------------------------------------------------------------------------- /docs/src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/img/architecture/create-and-enter-channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/architecture/create-and-enter-channel.png -------------------------------------------------------------------------------- /docs/static/img/architecture/create-channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/architecture/create-channel.png -------------------------------------------------------------------------------- /docs/static/img/architecture/direct-call.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/architecture/direct-call.png -------------------------------------------------------------------------------- /docs/static/img/architecture/enter-existing-channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/architecture/enter-existing-channel.png -------------------------------------------------------------------------------- /docs/static/img/architecture/first-user-enters-channel-with-sfu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/architecture/first-user-enters-channel-with-sfu.png -------------------------------------------------------------------------------- /docs/static/img/architecture/first-user-enters-channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/architecture/first-user-enters-channel.png -------------------------------------------------------------------------------- /docs/static/img/architecture/multiple-sfu-connections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/architecture/multiple-sfu-connections.png -------------------------------------------------------------------------------- /docs/static/img/architecture/second-user-enters-channel-with-sfu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/architecture/second-user-enters-channel-with-sfu.png -------------------------------------------------------------------------------- /docs/static/img/docusaurus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/docusaurus.png -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/static/img/tutorial/docsVersionDropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/tutorial/docsVersionDropdown.png -------------------------------------------------------------------------------- /docs/static/img/tutorial/initial-ui-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/tutorial/initial-ui-2.png -------------------------------------------------------------------------------- /docs/static/img/tutorial/initial-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/tutorial/initial-ui.png -------------------------------------------------------------------------------- /docs/static/img/tutorial/local-video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/tutorial/local-video.png -------------------------------------------------------------------------------- /docs/static/img/tutorial/localeDropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/tutorial/localeDropdown.png -------------------------------------------------------------------------------- /docs/static/img/tutorial/meet/create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/tutorial/meet/create.png -------------------------------------------------------------------------------- /docs/static/img/tutorial/meet/landing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/tutorial/meet/landing.png -------------------------------------------------------------------------------- /docs/static/img/tutorial/meet/meet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/tutorial/meet/meet.png -------------------------------------------------------------------------------- /docs/static/img/tutorial/meet/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/tutorial/meet/preview.png -------------------------------------------------------------------------------- /docs/static/img/tutorial/one-to-one-success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooyaho-webrtc/mooyaho/08892323d3eb55f16c2b8d2e5548e4194f4f6c24/docs/static/img/tutorial/one-to-one-success.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mooyaho", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "https://github.com/velopert/mooyaho.git", 6 | "author": "Minjun Kim ", 7 | "license": "MIT", 8 | "private": true, 9 | "workspaces": [ 10 | "packages/*" 11 | ], 12 | "nohoist": [ 13 | "**/mooyaho-init" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /packages/mooyaho-cli/.gitignore: -------------------------------------------------------------------------------- 1 | mooyaho-engine 2 | mooyaho-sfu -------------------------------------------------------------------------------- /packages/mooyaho-cli/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/node 2 | const download = require("download"); 3 | const os = require("os"); 4 | const path = require("path"); 5 | const fs = require("fs"); 6 | const tmpDir = os.tmpdir(); 7 | const crypto = require("crypto"); 8 | const { ncp } = require("ncp"); 9 | const { exec, execSync } = require("child_process"); 10 | 11 | async function isUsingYarn() { 12 | return new Promise((resolve) => { 13 | exec("yarn --version", (error) => { 14 | resolve(!error); 15 | }); 16 | }); 17 | } 18 | 19 | const [, , command, option] = process.argv; 20 | 21 | function generateHash() { 22 | return crypto.randomBytes(32).toString("hex"); 23 | } 24 | 25 | const RELEASE_URL = 26 | "https://github.com/mooyaho-webrtc/mooyaho/archive/refs/tags/v1.0.0-alpha.4.zip"; 27 | const fileName = path 28 | .basename(RELEASE_URL) 29 | .replace(/\.zip$/, "") 30 | .replace(/^v/, ""); 31 | const cwd = process.cwd(); 32 | 33 | async function downloadArchive() { 34 | await download(RELEASE_URL, tmpDir, { 35 | extract: true, 36 | }); 37 | const projectDirectory = path.join(tmpDir, `mooyaho-${fileName}`); 38 | return projectDirectory; 39 | } 40 | 41 | async function installDeps(cwd) { 42 | const packager = (await isUsingYarn()) ? "yarn" : "npm"; 43 | 44 | return new Promise((resolve, reject) => { 45 | const child = exec(`${packager} install`, { cwd }); 46 | 47 | child.stdout.on("data", (data) => { 48 | console.log(data.toString()); 49 | }); 50 | 51 | child.on("error", (error) => { 52 | resolve(error); 53 | }); 54 | child.addListener("exit", resolve); 55 | }); 56 | } 57 | 58 | async function initializeEngine() { 59 | const hasYarn = await isUsingYarn(); 60 | const projectDirectory = await downloadArchive(); 61 | const mooyahoEngineDirectory = path.join( 62 | projectDirectory, 63 | "/packages/mooyaho-engine" 64 | ); 65 | 66 | const targetDirectory = path.join(cwd, "/mooyaho-engine"); 67 | 68 | if (fs.existsSync(targetDirectory)) { 69 | console.error( 70 | "mooyaho-engine directory already exists in current directory" 71 | ); 72 | return; 73 | } 74 | 75 | // copy mooyahoEngineDirectory recursively 76 | fs.mkdirSync(targetDirectory); 77 | const promise = new Promise((resolve, reject) => { 78 | ncp(mooyahoEngineDirectory, targetDirectory, (err) => { 79 | if (err) { 80 | reject(err); 81 | return; 82 | } 83 | resolve(); 84 | }); 85 | }); 86 | 87 | try { 88 | await promise; 89 | } catch (e) { 90 | console.error(e); 91 | return; 92 | } 93 | 94 | const env = `PORT=8080 95 | SESSION_SECRET_KEY=${generateHash()} 96 | API_KEY=${generateHash()}`; 97 | 98 | const envFileDir = path.resolve(targetDirectory, "./.env"); 99 | fs.writeFileSync(envFileDir, env, "utf8"); 100 | 101 | await installDeps(targetDirectory); 102 | execSync(`${hasYarn ? "yarn" : "npm run"} prisma migrate dev --name init`, { 103 | cwd: targetDirectory, 104 | }); 105 | 106 | process.chdir(targetDirectory); 107 | } 108 | 109 | async function initializeSFU() { 110 | const projectDirectory = await downloadArchive(); 111 | const sfuDirectory = path.join(projectDirectory, "/packages/mooyaho-sfu"); 112 | const targetDirectory = path.join(cwd, "/mooyaho-sfu"); 113 | 114 | if (fs.existsSync(targetDirectory)) { 115 | console.error("mooyaho-sfu directory already exists in current directory"); 116 | return; 117 | } 118 | 119 | // copy mooyahoEngineDirectory recursively 120 | fs.mkdirSync(targetDirectory); 121 | const promise = new Promise((resolve, reject) => { 122 | ncp(sfuDirectory, targetDirectory, (err) => { 123 | if (err) { 124 | reject(err); 125 | return; 126 | } 127 | resolve(); 128 | }); 129 | }); 130 | 131 | try { 132 | await promise; 133 | } catch (e) { 134 | console.error(e); 135 | return; 136 | } 137 | 138 | await installDeps(targetDirectory); 139 | 140 | console.log(`mooyaho-sfu initialized at ${targetDirectory}`); 141 | } 142 | 143 | if (command === "init") { 144 | if (option === "engine") { 145 | initializeEngine(); 146 | } else if (option === "sfu") { 147 | initializeSFU(); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /packages/mooyaho-cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mooyaho-cli", 3 | "version": "1.0.1-alpha.5", 4 | "main": "index.js", 5 | "bin": "index.js", 6 | "license": "MIT", 7 | "dependencies": { 8 | "download": "^8.0.0", 9 | "has-yarn": "^2.1.0", 10 | "ncp": "^2.0.0" 11 | }, 12 | "scripts": { 13 | "start": "node index.js" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sample/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules -------------------------------------------------------------------------------- /packages/mooyaho-client-sample/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 80, 6 | "arrowParens": "avoid" 7 | } -------------------------------------------------------------------------------- /packages/mooyaho-client-sample/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sample/one-to-one/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |
11 |
12 | My Session ID: 13 | 14 |
15 |
16 | 17 | 18 |
19 | 20 |
21 |
22 |
23 | Me 24 |
25 |
26 |
27 |
28 | Other 29 |
30 |
31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sample/one-to-one/main.ts: -------------------------------------------------------------------------------- 1 | import Mooyaho, { MooyahoConfig } from 'mooyaho-client-sdk/src/index' 2 | 3 | const config: MooyahoConfig = { 4 | url: 'ws://localhost:8081', 5 | allowDirectCall: true, 6 | } 7 | 8 | const mooyaho = new Mooyaho(config) 9 | 10 | mooyaho 11 | .createUserMedia({ 12 | audio: true, 13 | video: true, 14 | }) 15 | .then(stream => { 16 | // add video tag to body and set stream 17 | const video = document.createElement('video') 18 | video.width = 200 19 | video.height = 200 20 | video.muted = true 21 | video.autoplay = true 22 | video.srcObject = stream 23 | document.getElementById('me').appendChild(video) 24 | }) 25 | 26 | mooyaho.connect().then(sessionId => { 27 | const sessionIdSpan = document.getElementById('session-id') 28 | sessionIdSpan.innerHTML = sessionId 29 | }) 30 | 31 | document.getElementById('call').addEventListener('click', () => { 32 | const sessionIdInput = document.getElementById( 33 | 'session-id-input' 34 | ) as HTMLInputElement 35 | const sessionId = sessionIdInput.value 36 | 37 | mooyaho.directCall(sessionId) 38 | }) 39 | 40 | mooyaho.addEventListener('remoteStreamChanged', e => { 41 | const otherDiv = document.getElementById('other') 42 | let video = otherDiv.querySelector('video') 43 | if (!video) { 44 | video = document.createElement('video') 45 | video.autoplay = true 46 | video.width = 200 47 | video.height = 200 48 | otherDiv.appendChild(video) 49 | } 50 | video.srcObject = mooyaho.getRemoteStreamById(e.sessionId) 51 | }) 52 | 53 | console.log('hello') 54 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sample/one-to-one/styles.css: -------------------------------------------------------------------------------- 1 | #other { 2 | display: flex; 3 | flex-wrap: wrap; 4 | } 5 | 6 | .session-id { 7 | font-size: 0.75rem; 8 | } 9 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sample/p2p-channel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |
11 |
12 | 13 | 14 |
15 | 16 | 17 |
OR
18 |
19 | 20 | 21 |
22 | 23 |
24 |
25 |
26 | Me 27 |
28 |
29 |
30 |
31 | Others 32 |
33 |
34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sample/p2p-channel/main.ts: -------------------------------------------------------------------------------- 1 | import './styles.css' 2 | import Mooyaho, { MooyahoConfig } from 'mooyaho-client-sdk/src/index' 3 | import MooyahoServerSDK from 'mooyaho-server-sdk' 4 | 5 | // CAUTION: Server SDK should be only used from server 6 | const serverSDK = new MooyahoServerSDK( 7 | 'http://localhost:8081', 8 | 'ec24c791f058b01abccc8e3c5e8cd50b' 9 | ) 10 | 11 | // select elements 12 | const createButton = document.getElementById('create-button') 13 | const createdChannelIdSpan = document.getElementById('created-channel-id') 14 | const enterButton = document.getElementById('enter-button') 15 | const channelIdInput = document.getElementById( 16 | 'channel-id-input' 17 | ) as HTMLInputElement 18 | const usernameInput = document.getElementById('username') as HTMLInputElement 19 | const leaveButton = document.getElementById('leave') 20 | const othersDiv = document.getElementById('others') 21 | 22 | // setup ui 23 | createButton.addEventListener('click', async () => { 24 | const channel = await serverSDK.createChannel() 25 | createdChannelIdSpan.innerHTML = `ID: ${channel.id}` 26 | mooyaho.enter(channel.id) 27 | }) 28 | 29 | enterButton.addEventListener('click', () => { 30 | mooyaho.enter(channelIdInput.value) 31 | }) 32 | 33 | leaveButton.addEventListener('click', () => { 34 | mooyaho.leave() 35 | }) 36 | 37 | const config: MooyahoConfig = { 38 | url: 'ws://localhost:8081', 39 | } 40 | 41 | const mooyaho = new Mooyaho(config) 42 | 43 | mooyaho 44 | .createUserMedia({ 45 | audio: true, 46 | video: { 47 | width: { max: 720 }, 48 | height: { max: 480 }, 49 | }, 50 | }) 51 | .then(stream => { 52 | // add video tag to body and set stream 53 | const video = document.createElement('video') 54 | video.width = 200 55 | video.height = 200 56 | video.muted = true 57 | video.autoplay = true 58 | video.srcObject = stream 59 | document.getElementById('me').appendChild(video) 60 | }) 61 | 62 | let integrated = false 63 | 64 | mooyaho.addEventListener('connected', e => { 65 | console.log('Successfully connected to Mooyaho Server') 66 | console.log(`Session ID: ${e.sessionId}`) 67 | // NOTE: To intergate user from browser, `allowAnonymous` field 68 | // should be true from mooyaho.config.json of mooyaho-server. 69 | // In normal cases, user should be integrated by using server SDK 70 | if (!integrated) { 71 | mooyaho.integrateUser({ 72 | username: usernameInput.value, 73 | }) 74 | integrated = true 75 | } 76 | }) 77 | 78 | mooyaho.addEventListener('enterSuccess', e => { 79 | console.log(`Successfully entered to channel ${mooyaho.channelId}`) 80 | console.log(`SFU is ${e.sfuEnabled ? 'enabled' : 'disabled'} in this channel`) 81 | leaveButton.style.display = 'block' 82 | 83 | const sessionArray = mooyaho.sessionsArray 84 | 85 | sessionArray.forEach(session => { 86 | if (session.id === mooyaho.sessionId) return 87 | const sessionDiv = createSessionDiv(session.id, session.user.username) 88 | othersDiv.appendChild(sessionDiv) 89 | }) 90 | }) 91 | 92 | function createSessionDiv(sessionId: string, username: string) { 93 | const sessionDiv = document.createElement('div') 94 | sessionDiv.id = sessionId 95 | sessionDiv.innerHTML = ` 96 | 97 |
${sessionId}
98 |
${username}
99 | ` 100 | return sessionDiv 101 | } 102 | 103 | mooyaho.addEventListener('entered', e => { 104 | console.group('User Entered') 105 | console.log(e) 106 | console.groupEnd() 107 | 108 | if (e.isSelf) return 109 | const sessionDiv = createSessionDiv(e.sessionId, e.user.username) 110 | othersDiv.appendChild(sessionDiv) 111 | }) 112 | 113 | mooyaho.addEventListener('left', e => { 114 | console.group('User Left') 115 | console.log(e.sessionId) 116 | console.groupEnd() 117 | 118 | // find video tag by sessionId and remove it 119 | const video = document.getElementById(e.sessionId) 120 | if (video) { 121 | video.parentNode.removeChild(video) 122 | } 123 | }) 124 | 125 | mooyaho.addEventListener('remoteStreamChanged', e => { 126 | // select div by e.sessionId 127 | const sessionDiv = document.getElementById(e.sessionId) 128 | if (!sessionDiv) return 129 | 130 | const stream = mooyaho.getRemoteStreamById(e.sessionId) 131 | 132 | const video = sessionDiv.querySelector('video') 133 | video.srcObject = stream 134 | }) 135 | 136 | mooyaho.addEventListener('reconnected', () => { 137 | othersDiv.innerHTML = '' 138 | }) 139 | 140 | mooyaho.connect() 141 | 142 | // for debug purpose 143 | ;(window as any).mooyaho = mooyaho 144 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sample/p2p-channel/styles.css: -------------------------------------------------------------------------------- 1 | .userblock { 2 | margin-bottom: 1rem; 3 | } 4 | 5 | .or { 6 | margin-top: 0.5rem; 7 | margin-bottom: 0.5rem; 8 | } 9 | 10 | #leave { 11 | display: none; 12 | margin-top: 1rem; 13 | } 14 | 15 | #others { 16 | display: flex; 17 | flex-wrap: wrap; 18 | } 19 | 20 | .session-id { 21 | font-size: 0.75rem; 22 | } 23 | .username { 24 | font-size: 1rem; 25 | } 26 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sample/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mooyaho-client-sample-parcel", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "devDependencies": { 7 | "parcel": "^2.0.0-beta.2" 8 | }, 9 | "scripts": { 10 | "start": "parcel ./*.html" 11 | }, 12 | "dependencies": {} 13 | } 14 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sample/sfu-channel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 |
11 |
12 | 13 | 14 |
15 | 16 | 17 |
OR
18 |
19 | 20 | 21 |
22 | 23 |
24 |
25 |
26 | Me 27 |
28 |
29 |
30 |
31 | Others 32 |
33 |
34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sample/sfu-channel/main.ts: -------------------------------------------------------------------------------- 1 | import './styles.css' 2 | import Mooyaho, { MooyahoConfig } from 'mooyaho-client-sdk/src/index' 3 | import MooyahoServerSDK from 'mooyaho-server-sdk' 4 | 5 | // CAUTION: Server SDK should be only used from server 6 | const serverSDK = new MooyahoServerSDK( 7 | 'http://localhost:8080', 8 | 'ec24c791f058b01abccc8e3c5e8cd50b' 9 | ) 10 | 11 | // select elements 12 | const createButton = document.getElementById('create-button') 13 | const createdChannelIdSpan = document.getElementById('created-channel-id') 14 | const enterButton = document.getElementById('enter-button') 15 | const channelIdInput = document.getElementById( 16 | 'channel-id-input' 17 | ) as HTMLInputElement 18 | const usernameInput = document.getElementById('username') as HTMLInputElement 19 | const leaveButton = document.getElementById('leave') 20 | const othersDiv = document.getElementById('others') 21 | 22 | // setup ui 23 | createButton.addEventListener('click', async () => { 24 | // create channel with isSFU: true 25 | const channel = await serverSDK.createChannel(true) 26 | createdChannelIdSpan.innerHTML = `ID: ${channel.id}` 27 | mooyaho.enter(channel.id) 28 | }) 29 | 30 | enterButton.addEventListener('click', () => { 31 | mooyaho.enter(channelIdInput.value) 32 | }) 33 | 34 | leaveButton.addEventListener('click', () => { 35 | mooyaho.leave() 36 | if (othersDiv) { 37 | othersDiv.innerHTML = '' 38 | } 39 | }) 40 | 41 | const config: MooyahoConfig = { 42 | url: 'ws://localhost:8081', 43 | } 44 | 45 | const mooyaho = new Mooyaho(config) 46 | 47 | mooyaho 48 | .createUserMedia({ 49 | audio: true, 50 | video: true, 51 | }) 52 | .then(stream => { 53 | // add video tag to body and set stream 54 | const video = document.createElement('video') 55 | video.width = 200 56 | video.height = 200 57 | video.muted = true 58 | video.autoplay = true 59 | video.srcObject = stream 60 | document.getElementById('me').appendChild(video) 61 | }) 62 | 63 | mooyaho.addEventListener('connected', e => { 64 | console.log('Successfully connected to Mooyaho Server') 65 | console.log(`Session ID: ${e.sessionId}`) 66 | // NOTE: To intergate user from browser, `allowAnonymous` field 67 | // should be true from mooyaho.config.json of mooyaho-server. 68 | // In normal cases, user should be integrated by using server SDK 69 | mooyaho.integrateUser({ 70 | username: usernameInput.value, 71 | }) 72 | }) 73 | 74 | mooyaho.addEventListener('enterSuccess', e => { 75 | console.log(`Successfully entered to channel ${mooyaho.channelId}`) 76 | console.log(`SFU is ${e.sfuEnabled ? 'enabled' : 'disabled'} in this channel`) 77 | leaveButton.style.display = 'block' 78 | 79 | const sessionArray = mooyaho.sessionsArray 80 | 81 | sessionArray.forEach(session => { 82 | if (session.id === mooyaho.sessionId) return 83 | const sessionDiv = createSessionDiv(session.id, session.user.username) 84 | othersDiv.appendChild(sessionDiv) 85 | }) 86 | }) 87 | 88 | function createSessionDiv(sessionId: string, username: string) { 89 | const sessionDiv = document.createElement('div') 90 | sessionDiv.id = sessionId 91 | sessionDiv.innerHTML = ` 92 | 93 |
${sessionId}
94 |
${username}
95 | ` 96 | return sessionDiv 97 | } 98 | 99 | mooyaho.addEventListener('entered', e => { 100 | console.group('User Entered') 101 | console.log(e) 102 | console.groupEnd() 103 | 104 | if (e.isSelf) return 105 | const sessionDiv = createSessionDiv(e.sessionId, e.user.username) 106 | othersDiv.appendChild(sessionDiv) 107 | }) 108 | 109 | mooyaho.addEventListener('left', e => { 110 | console.group('User Left') 111 | console.log(e.sessionId) 112 | console.groupEnd() 113 | 114 | // find video tag by sessionId and remove it 115 | const video = document.getElementById(e.sessionId) 116 | if (video) { 117 | video.parentNode.removeChild(video) 118 | } 119 | }) 120 | 121 | mooyaho.addEventListener('remoteStreamChanged', e => { 122 | // select div by e.sessionId 123 | const sessionDiv = document.getElementById(e.sessionId) 124 | if (!sessionDiv) return 125 | 126 | const stream = mooyaho.getRemoteStreamById(e.sessionId) 127 | 128 | const video = sessionDiv.querySelector('video') 129 | video.srcObject = stream 130 | }) 131 | 132 | mooyaho.connect() 133 | 134 | // for debug purpose 135 | ;(window as any).mooyaho = mooyaho 136 | 137 | console.log('hello') 138 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sample/sfu-channel/styles.css: -------------------------------------------------------------------------------- 1 | .userblock { 2 | margin-bottom: 1rem; 3 | } 4 | 5 | .or { 6 | margin-top: 0.5rem; 7 | margin-bottom: 0.5rem; 8 | } 9 | 10 | #leave { 11 | display: none; 12 | margin-top: 1rem; 13 | } 14 | 15 | #others { 16 | display: flex; 17 | flex-wrap: wrap; 18 | } 19 | 20 | .session-id { 21 | font-size: 0.75rem; 22 | } 23 | .username { 24 | font-size: 1rem; 25 | } 26 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sdk/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /packages/mooyaho-client-sdk/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 80, 6 | "arrowParens": "avoid" 7 | } -------------------------------------------------------------------------------- /packages/mooyaho-client-sdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mooyaho/client", 3 | "version": "1.0.0-alpha.1", 4 | "main": "dist/cjs/bundle.js", 5 | "module": "dist/es/bundle.js", 6 | "types": "dist/types/mooyaho-client-sdk/src/index.d.ts", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "@rollup/plugin-typescript": "^8.2.1", 10 | "typescript": "^4.3.5" 11 | }, 12 | "scripts": { 13 | "build": "yarn rollup -c rollup.config.js", 14 | "build:types": "yarn tsc --emitDeclarationOnly" 15 | }, 16 | "dependencies": { 17 | "eventemitter3": "^4.0.7" 18 | }, 19 | "files": [ 20 | "/dist" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sdk/rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript' 2 | 3 | export default { 4 | input: 'src/index.ts', 5 | output: [ 6 | { 7 | format: 'cjs', 8 | file: 'dist/cjs/bundle.js', 9 | }, 10 | { 11 | format: 'es', 12 | file: 'dist/es/bundle.js', 13 | }, 14 | ], 15 | plugins: [typescript({ module: 'ESNext' })], 16 | } 17 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sdk/src/Events.ts: -------------------------------------------------------------------------------- 1 | export interface EventMap { 2 | connected: { sessionId: string } 3 | enterSuccess: { sfuEnabled: boolean } 4 | entered: { sessionId: string; user: any; isSelf: boolean } 5 | left: { sessionId: string } 6 | remoteStreamChanged: { sessionId: string } 7 | sfuPeerConnected: { peer: RTCPeerConnection } 8 | peerConnected: { sessionId: string; peer: RTCPeerConnection } 9 | reconnected: { sessionId: string } 10 | updatedMediaState: { 11 | sessionId: string 12 | key: 'muted' | 'videoOff' 13 | value: boolean 14 | isSelf: boolean 15 | } 16 | } 17 | 18 | export type EventType = keyof EventMap 19 | 20 | export interface LocalEventMap { 21 | listSessions: { 22 | sessions: { 23 | id: string 24 | user: any 25 | state: { muted: false; videoOff: false } 26 | }[] 27 | } 28 | } 29 | export type LocalEventType = keyof LocalEventMap 30 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sdk/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Mooyaho' 2 | export { default } from './Mooyaho' 3 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sdk/src/utils/waitUntil.ts: -------------------------------------------------------------------------------- 1 | const sleep = (time: number) => 2 | new Promise(resolve => { 3 | setTimeout(resolve, time) 4 | }) 5 | 6 | export function waitUntil(fn: () => boolean, timeout: number = 10000) { 7 | return new Promise(async (resolve, reject) => { 8 | const timeoutId = setTimeout(() => { 9 | reject(new Error('waitUntil timed out')) 10 | }, timeout) 11 | while (!fn()) { 12 | await sleep(50) 13 | } 14 | clearTimeout(timeoutId) 15 | resolve() 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /packages/mooyaho-client-sdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | "lib": ["DOM","ES2015", "ES2016", "ES2017", "ES2018", "ES2019"], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 13 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | "declarationDir": "dist/types", 15 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 16 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 17 | // "outFile": "./", /* Concatenate and emit output to single file. */ 18 | // "outDir": "./", /* Redirect output structure to the directory. */ 19 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 20 | // "composite": true, /* Enable project compilation */ 21 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 22 | // "removeComments": true, /* Do not emit comments to output. */ 23 | // "noEmit": true, /* Do not emit outputs. */ 24 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 25 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 26 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 27 | 28 | /* Strict Type-Checking Options */ 29 | "strict": true, /* Enable all strict type-checking options. */ 30 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 31 | // "strictNullChecks": true, /* Enable strict null checks. */ 32 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 33 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 34 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 35 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 36 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 37 | 38 | /* Additional Checks */ 39 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 40 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 41 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 42 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 43 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 44 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ 45 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 46 | 47 | /* Module Resolution Options */ 48 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 49 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 50 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 51 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 52 | // "typeRoots": [], /* List of folders to include type definitions from. */ 53 | // "types": [], /* Type declaration files to be included in compilation. */ 54 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 55 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 56 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 57 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 58 | 59 | /* Source Map Options */ 60 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 61 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 62 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 63 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 64 | 65 | /* Experimental Options */ 66 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 67 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 68 | 69 | /* Advanced Options */ 70 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 71 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/.env.sample: -------------------------------------------------------------------------------- 1 | PORT=8080 2 | SESSION_SECRET_KEY=KeyForSessionReuse 3 | API_KEY=KeyForServerSDK 4 | CORS=true 5 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # 0x 40 | profile-* 41 | 42 | # mac files 43 | .DS_Store 44 | 45 | # vim swap files 46 | *.swp 47 | 48 | # webstorm 49 | .idea 50 | 51 | # vscode 52 | .vscode 53 | *code-workspace 54 | 55 | # clinic 56 | profile* 57 | *clinic* 58 | *flamegraph* 59 | 60 | # generated code 61 | examples/typescript-server.js 62 | test/types/index.js 63 | 64 | # compiled app 65 | dist 66 | 67 | 68 | .env 69 | **/migrations 70 | dev.db 71 | dev.db-journal 72 | .sessionIds -------------------------------------------------------------------------------- /packages/mooyaho-engine/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 80, 6 | "arrowParens": "avoid" 7 | } -------------------------------------------------------------------------------- /packages/mooyaho-engine/.taprc: -------------------------------------------------------------------------------- 1 | test-env: [ 2 | TS_NODE_PROJECT=./test/tsconfig.test.json 3 | ] 4 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/mooyaho.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "allowAnonymous": true 3 | } -------------------------------------------------------------------------------- /packages/mooyaho-engine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mooyaho/engine", 3 | "version": "1.0.0-alpha.1", 4 | "description": "", 5 | "main": "app.ts", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "npm run build:ts && tsc -p test/tsconfig.test.json && cross-env TS_NODE_FILES=true tap test/**/*.test.ts", 11 | "start": "ts-node -T ./src/server.ts", 12 | "build:ts": "tsc", 13 | "dev": "tsc && concurrently -k -p \"[{name}]\" -n \"TypeScript,App\" -c \"yellow.bold,cyan.bold\" \"tsc -w\" \"fastify start --ignore-watch=.ts$ -w -l info -P dist/app.js\"", 14 | "prisma:generate": "prisma generate", 15 | "schema:generate": " json2ts -i src/schemas -o src/schema-types" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "dependencies": { 21 | "@dnlup/fastify-traps": "^1.1.2", 22 | "@mooyaho/grpc": "^1.0.0", 23 | "@prisma/client": "^2.23.0", 24 | "@types/uuid": "^8.3.0", 25 | "@types/ws": "^7.4.4", 26 | "close-with-grace": "^1.1.0", 27 | "fastify": "^3.0.0", 28 | "fastify-autoload": "^3.3.1", 29 | "fastify-cli": "^2.11.0", 30 | "fastify-cors": "^6.0.1", 31 | "fastify-plugin": "^3.0.0", 32 | "fastify-sensible": "^3.1.0", 33 | "fastify-swagger": "^4.7.0", 34 | "fastify-websocket": "^3.2.0", 35 | "grpc": "^1.24.10", 36 | "node-graceful-shutdown": "^1.1.0", 37 | "prisma": "2.23.0", 38 | "redis": "^3.1.2", 39 | "typeorm": "^0.2.32", 40 | "uuid": "^8.3.2", 41 | "ws": "^7.4.5" 42 | }, 43 | "devDependencies": { 44 | "@types/node": "^15.0.0", 45 | "@types/redis": "^2.8.28", 46 | "@types/tap": "^15.0.0", 47 | "concurrently": "^6.0.0", 48 | "cross-env": "^7.0.3", 49 | "dotenv": "^9.0.2", 50 | "fastify-tsconfig": "^1.0.1", 51 | "json-schema-to-typescript": "^10.1.4", 52 | "tap": "^14.11.0", 53 | "ts-node": "^10.1.0", 54 | "typescript": "^4.1.3" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "sqlite" 6 | url = "file:./dev.db" 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | } 12 | 13 | model Channel { 14 | id String @id 15 | sfuServerId Int? 16 | 17 | ChannelSessions ChannelSession[] 18 | sfuServer SfuServer? @relation(fields: [sfuServerId], references: [id]) 19 | } 20 | 21 | model Session { 22 | id String @id 23 | userId String 24 | state String 25 | 26 | 27 | user User @relation(fields: [userId], references: [id]) 28 | ChannelSessions ChannelSession[] 29 | } 30 | 31 | model ChannelSession { 32 | id Int @id @default(autoincrement()) 33 | channelId String 34 | sessionId String 35 | 36 | 37 | channel Channel @relation(fields: [channelId], references: [id]) 38 | session Session @relation(fields: [sessionId], references: [id]) 39 | } 40 | 41 | model User { 42 | id String @id 43 | json String 44 | 45 | Session Session[] 46 | } 47 | 48 | model SfuServer { 49 | id Int @id @default(autoincrement()) 50 | address String 51 | disabled Boolean @default(false) 52 | Channel Channel[] 53 | } 54 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/app.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | import { join } from 'path' 3 | import AutoLoad, { AutoloadPluginOptions } from 'fastify-autoload' 4 | import { FastifyPluginAsync } from 'fastify' 5 | import prisma from './lib/prisma' 6 | import { cleanSessions, disconnectAllSessions } from './routes/websocket' 7 | import { startClosing } from './lib/close' 8 | 9 | export type AppOptions = { 10 | // Place your custom options for app below here. 11 | } & Partial 12 | 13 | const app: FastifyPluginAsync = async ( 14 | fastify, 15 | opts 16 | ): Promise => { 17 | await prisma.$connect() 18 | await cleanSessions() 19 | 20 | fastify.setErrorHandler((error, request, reply) => { 21 | // if (isMooyahoError(error)) { 22 | // reply.status(error.statusCode) 23 | // } 24 | reply.send(error) 25 | }) 26 | // Do not touch the following lines 27 | 28 | // This loads all plugins defined in plugins 29 | // those should be support plugins that are reused 30 | // through your application 31 | void fastify.register(AutoLoad, { 32 | dir: join(__dirname, 'plugins'), 33 | options: opts, 34 | }) 35 | 36 | // This loads all plugins defined in routes 37 | // define your routes in one of these 38 | void fastify.register(AutoLoad, { 39 | dir: join(__dirname, 'routes'), 40 | options: opts, 41 | }) 42 | } 43 | 44 | export default app 45 | export { app } 46 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/configLoader.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | type MooyahoConfig = { 5 | allowAnonymous: boolean 6 | } 7 | 8 | let config: MooyahoConfig | null = null 9 | 10 | async function loadConfig() { 11 | const configDir = path.resolve(__dirname, '../mooyaho.config.json') 12 | const file = fs.readFileSync(configDir, 'utf8') 13 | config = JSON.parse(file) 14 | } 15 | 16 | loadConfig() 17 | 18 | export default config! 19 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/MooyahoError.ts: -------------------------------------------------------------------------------- 1 | export class MooyahoError extends Error { 2 | statusCode: number = 400 3 | constructor(params: ErrorParams) { 4 | super(params.message) 5 | params.name = params.name 6 | if (params.statusCode) { 7 | this.statusCode = params.statusCode 8 | } 9 | } 10 | } 11 | 12 | type ErrorName = 'Not Found' | 'Bad Request' | 'Unauthorized' | 'Internal Error' 13 | 14 | type ErrorParams = { 15 | statusCode?: number 16 | message: string 17 | name: ErrorName 18 | } 19 | 20 | export function isMooyahoError(e: any): e is MooyahoError { 21 | console.log(e instanceof MooyahoError) 22 | return e instanceof MooyahoError 23 | } 24 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/SFUManager.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@mooyaho/grpc' 2 | import sfuServerService from '../services/sfuServerService' 3 | import prisma from './prisma' 4 | import actionCreators from './websocket/actions/send' 5 | import { localSubscriber } from './websocket/redis/createRedisClient' 6 | import prefixer from './websocket/redis/prefixer' 7 | import subscription from './websocket/redis/subscription' 8 | 9 | class SFUManager { 10 | sfuClientMap = new Map() 11 | 12 | async initialize() { 13 | const servers = await sfuServerService.list() 14 | servers.forEach(server => { 15 | const client = new Client(server.address) 16 | this.sfuClientMap.set(server.id, client) 17 | this.startListenSignal(client) 18 | }) 19 | this.subscribeSFUCreated() 20 | } 21 | 22 | private startListenSignal(client: Client) { 23 | client 24 | .listenSignal(signal => { 25 | if (signal.type === 'icecandidate') { 26 | subscription.dispatch( 27 | prefixer.direct(signal.sessionId), 28 | actionCreators.candidated( 29 | signal.fromSessionId!, 30 | JSON.parse(signal.candidate), 31 | true 32 | ) 33 | ) 34 | } else if (signal.type === 'offer') { 35 | subscription.dispatch( 36 | prefixer.direct(signal.sessionId), 37 | actionCreators.called(signal.fromSessionId, signal.sdp, true) 38 | ) 39 | } 40 | }) 41 | .catch(e => { 42 | setTimeout(() => this.startListenSignal(client), 250) 43 | }) 44 | } 45 | 46 | getClient(id: number) { 47 | return this.sfuClientMap.get(id) 48 | } 49 | 50 | async getClientOf(channelId: string) { 51 | const sfuServer = await sfuServerService.findSFUServerOf(channelId) 52 | if (!sfuServer) return null 53 | return this.getClient(sfuServer.id) 54 | } 55 | 56 | private async addClient(id: number) { 57 | const sfuServer = await sfuServerService.getSFUServerById(id) 58 | if (!sfuServer) { 59 | console.error('SFUServer is not yet registered to database') 60 | return 61 | } 62 | 63 | if (this.sfuClientMap.has(id)) { 64 | console.info('SFUClient is already registered') 65 | return 66 | } 67 | 68 | const client = new Client(sfuServer.address) 69 | this.sfuClientMap.set(id, client) 70 | this.startListenSignal(client) 71 | console.log(`Adding SFU Server ${id}`) 72 | } 73 | 74 | private async subscribeSFUCreated() { 75 | localSubscriber.subscribe('sfu_created') 76 | localSubscriber.on('message', (channel, message) => { 77 | console.log(message) 78 | if (channel !== 'sfu_created') return 79 | 80 | try { 81 | const serverId = parseInt(message) 82 | if (isNaN(serverId)) { 83 | console.error(`${serverId} is NaN`) 84 | return 85 | } 86 | this.addClient(serverId) 87 | } catch (e) { 88 | console.error( 89 | `Failed to parse message from redis subscription "${message}"` 90 | ) 91 | } 92 | }) 93 | } 94 | } 95 | 96 | export default SFUManager 97 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/close.ts: -------------------------------------------------------------------------------- 1 | let closing = false 2 | 3 | export const getClosing = () => closing 4 | export const startClosing = () => { 5 | closing = true 6 | } 7 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/plugins/protect.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This plugin protects API with API key 3 | */ 4 | 5 | import { FastifyPluginAsync } from 'fastify' 6 | import fp from 'fastify-plugin' 7 | 8 | const callback: FastifyPluginAsync = async fastify => { 9 | fastify.addHook('preHandler', async (request, reply) => { 10 | const token = request.headers.authorization?.split('Bearer ')[1] 11 | if (token !== process.env.API_KEY) { 12 | reply.status(401) 13 | throw new Error('API Key is missing') 14 | } 15 | }) 16 | } 17 | 18 | const protect = fp(callback, { 19 | name: 'protect', 20 | }) 21 | 22 | export default protect 23 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '.prisma/client' 2 | 3 | const prisma = new PrismaClient() 4 | 5 | export default prisma 6 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/websocket/Session.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid' 2 | import WebSocket from 'ws' 3 | import { 4 | AnswerAction, 5 | CallAction, 6 | CandidateAction, 7 | Message, 8 | ReceiveAction, 9 | UpdateMediaStateAction, 10 | } from './actions/receive' 11 | import actionCreators from './actions/send' 12 | import { createHmac } from 'crypto' 13 | import subscription from './redis/subscription' 14 | import channelHelper from './channelHelper' 15 | import prefixer from './redis/prefixer' 16 | import rtcHelper from './rtcHelper' 17 | import sessionService from '../../services/sessionService' 18 | import channelService from '../../services/channelService' 19 | import config from '../../configLoader' 20 | import SFUManager from '../SFUManager' 21 | 22 | const sfuManager = new SFUManager() 23 | sfuManager.initialize() 24 | 25 | const { SESSION_SECRET_KEY } = process.env 26 | 27 | if (!SESSION_SECRET_KEY) { 28 | console.warn('SESSION_SECRET_KEY is not defined') 29 | } 30 | 31 | class Session { 32 | id: string 33 | private token: string 34 | private currentChannel: string | null = null 35 | private unsubscriptionMap = new Map void>() 36 | connectedToSFU: boolean = false 37 | sfuId: number | null = null 38 | 39 | constructor(private socket: WebSocket) { 40 | this.id = v4() 41 | this.token = createHmac('sha256', SESSION_SECRET_KEY!) 42 | .update(this.id) 43 | .digest('hex') 44 | 45 | this.informConnected() 46 | this.subscribe(prefixer.direct(this.id)) 47 | } 48 | 49 | sendJSON(data: any) { 50 | this.socket.send(JSON.stringify(data)) 51 | } 52 | 53 | private informConnected() { 54 | const action = actionCreators.connected(this.id, this.token) 55 | this.sendJSON(action) 56 | } 57 | 58 | handle(action: ReceiveAction) { 59 | switch (action.type) { 60 | case 'getId': { 61 | this.handleGetId() 62 | break 63 | } 64 | case 'reuseId': { 65 | this.handleReuseId(action.id, action.token) 66 | break 67 | } 68 | case 'subscribe': { 69 | this.handleSubscribe(action.key) 70 | break 71 | } 72 | case 'unsubscribe': { 73 | this.handleUnsubscribe(action.key) 74 | break 75 | } 76 | case 'enter': { 77 | this.handleEnter(action.channel) 78 | break 79 | } 80 | case 'leave': { 81 | this.handleLeave() 82 | break 83 | } 84 | case 'message': { 85 | this.handleMessage(action.message) 86 | break 87 | } 88 | case 'listSessions': { 89 | this.handleListSessions() 90 | break 91 | } 92 | case 'call': { 93 | this.handleCall(action) 94 | break 95 | } 96 | case 'answer': { 97 | this.handleAnswer(action) 98 | break 99 | } 100 | case 'candidate': { 101 | this.handleCandidate(action) 102 | break 103 | } 104 | case 'integrateUser': { 105 | this.handleIntegrateUser(action.user) 106 | break 107 | } 108 | case 'updateMediaState': { 109 | this.handleUpdateMediaState(action) 110 | break 111 | } 112 | // case 'SFUCandidate': { 113 | // this.handleSFUCandidate(action.candidate, action.sessionId) 114 | // break 115 | // } 116 | // case 'SFUAnswer': { 117 | // this.handleSFUAnswer(action.sessionId, action.sdp) 118 | // } 119 | } 120 | } 121 | 122 | async subscribe(key: string) { 123 | const unsubscribe = await subscription.subscribe(key, this) 124 | this.unsubscriptionMap.set(key, unsubscribe) 125 | } 126 | 127 | unsubscribe(key: string) { 128 | const unsubscribe = this.unsubscriptionMap.get(key) 129 | unsubscribe?.() 130 | this.unsubscriptionMap.delete(key) 131 | } 132 | 133 | private handleGetId() { 134 | const action = actionCreators.getIdSuccess(this.id) 135 | this.sendJSON(action) 136 | } 137 | 138 | private async handleReuseId(id: string, token: string) { 139 | const generatedToken = createHmac('sha256', SESSION_SECRET_KEY!) 140 | .update(id) 141 | .digest('hex') 142 | 143 | if (token === generatedToken) { 144 | try { 145 | const user = await sessionService.reintegrate(this.id, id) 146 | this.sendJSON(actionCreators.reuseIdSuccess(user)) 147 | } catch (e) { 148 | console.error(e) 149 | } 150 | } 151 | } 152 | 153 | private handleSubscribe(key: string) { 154 | this.subscribe(key) 155 | const action = actionCreators.subscriptionSuccess(key) 156 | this.sendJSON(action) 157 | } 158 | 159 | private handleUnsubscribe(key: string) { 160 | this.unsubscribe(key) 161 | } 162 | 163 | private async handleEnter(channelId: string) { 164 | const channel = await channelService.findById(channelId) 165 | if (!channel) { 166 | // TODO: send error 167 | return 168 | } 169 | 170 | const user = await sessionService.getUserBySessionId(this.id) 171 | if (!user) { 172 | // TODO: send error 173 | return 174 | } 175 | 176 | await this.subscribe(prefixer.channel(channelId)) 177 | if (channel.sfuServerId) { 178 | this.connectedToSFU = true 179 | this.sfuId = channel.sfuServerId 180 | } 181 | this.sendJSON(actionCreators.enterSuccess(!!channel.sfuServerId)) 182 | 183 | channelHelper.enter(channelId, this.id, user) 184 | this.currentChannel = channelId 185 | } 186 | 187 | private handleLeave() { 188 | if (!this.currentChannel) return 189 | this.unsubscribe(prefixer.channel(this.currentChannel)) 190 | 191 | channelHelper.leave(this.currentChannel, this.id) 192 | this.currentChannel = null 193 | } 194 | 195 | private handleMessage(message: Message) { 196 | if (!this.currentChannel) return 197 | channelHelper.message(this.currentChannel, this.id, message) 198 | } 199 | 200 | async handleListSessions() { 201 | if (!this.currentChannel) return 202 | try { 203 | const sessions = await channelService.listUsers(this.currentChannel) 204 | this.sendJSON(actionCreators.listSessionsSuccess(sessions)) 205 | } catch (e) { 206 | console.error(e) 207 | } 208 | } 209 | 210 | async handleCall(action: CallAction) { 211 | if (action.isSFU) { 212 | if (!this.currentChannel || !this.sfuId) return 213 | try { 214 | const sfuClient = sfuManager.getClient(this.sfuId) 215 | if (!sfuClient) return 216 | const result = await sfuClient.call({ 217 | channelId: this.currentChannel, 218 | sessionId: this.id, 219 | sdp: action.sdp, 220 | }) 221 | 222 | this.sendJSON(actionCreators.answered(undefined, result, true)) 223 | } catch (e) { 224 | console.log(e) 225 | } 226 | } else { 227 | rtcHelper.call({ 228 | from: this.id, 229 | to: action.to, 230 | sdp: action.sdp, 231 | }) 232 | } 233 | } 234 | 235 | handleAnswer(action: AnswerAction) { 236 | const { isSFU, to, sdp } = action 237 | if (isSFU) { 238 | if (!this.currentChannel || !this.sfuId) return 239 | const sfuClient = sfuManager.getClient(this.sfuId) 240 | if (!sfuClient) return 241 | 242 | sfuClient.answer({ 243 | channelId: this.currentChannel, 244 | fromSessionId: this.id, 245 | sdp, 246 | sessionId: to, 247 | }) 248 | } else { 249 | rtcHelper.answer({ 250 | from: this.id, 251 | to, 252 | sdp, 253 | }) 254 | } 255 | } 256 | 257 | handleCandidate(action: CandidateAction) { 258 | const { to, isSFU, candidate } = action 259 | 260 | if (isSFU && this.sfuId) { 261 | const sfuClient = sfuManager.getClient(this.sfuId) 262 | if (!sfuClient) return 263 | try { 264 | // ensures answer first 265 | setTimeout(() => { 266 | sfuClient.clientIcecandidate({ 267 | sessionId: to, 268 | fromSessionId: this.id, 269 | candidate: JSON.stringify(candidate), 270 | }) 271 | }, 50) 272 | } catch (e) {} 273 | } else { 274 | rtcHelper.candidate({ 275 | from: this.id, 276 | to: to!, 277 | candidate, 278 | }) 279 | } 280 | } 281 | 282 | async handleIntegrateUser(user: Record) { 283 | if (!config.allowAnonymous) return 284 | const userWithSessionId = { 285 | ...user, 286 | id: this.id, 287 | } 288 | await sessionService.integrate(this.id, JSON.stringify(userWithSessionId)) 289 | this.sendJSON(actionCreators.integrated(userWithSessionId)) 290 | } 291 | 292 | async handleUpdateMediaState(action: UpdateMediaStateAction) { 293 | if (!this.currentChannel) return 294 | sessionService.updateState(this.id, action.key, action.value) 295 | channelHelper.broadcast( 296 | this.currentChannel, 297 | actionCreators.updatedState(this.id, action.key, action.value) 298 | ) 299 | } 300 | 301 | public sendSubscriptionMessage(key: string, message: any) { 302 | // const action = actionCreators.subscriptionMessage(key, message) 303 | this.sendJSON(message) 304 | } 305 | 306 | async dispose() { 307 | const fns = Array.from(this.unsubscriptionMap.values()) 308 | fns.forEach(fn => fn()) 309 | // remove from channel 310 | if (!this.currentChannel) return 311 | 312 | console.log('remove session from channel') 313 | await channelHelper.leave(this.currentChannel, this.id) 314 | console.log('removed session') 315 | if (this.connectedToSFU && this.sfuId) { 316 | const sfuClient = sfuManager.getClient(this.sfuId) 317 | await sfuClient?.leave({ 318 | sessionId: this.id, 319 | channelId: this.currentChannel, 320 | }) 321 | } 322 | this.currentChannel = null 323 | console.log('disposed ', this.id) 324 | } 325 | } 326 | 327 | export default Session 328 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/websocket/actions/common.ts: -------------------------------------------------------------------------------- 1 | export type Description = { 2 | sdp: string 3 | type: 'offer' | 'answer' 4 | } 5 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/websocket/actions/receive.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * actions that server receives 3 | */ 4 | 5 | import { Description } from './common' 6 | 7 | type GetIdAction = { 8 | type: 'getId' 9 | } 10 | 11 | type ReuseIdAction = { 12 | type: 'reuseId' 13 | id: string 14 | token: string 15 | } 16 | 17 | type SubscribeAction = { 18 | type: 'subscribe' 19 | key: string 20 | } 21 | 22 | type UnsubscribeAction = { 23 | type: 'unsubscribe' 24 | key: string 25 | } 26 | 27 | type EnterAction = { 28 | type: 'enter' 29 | channel: string 30 | } 31 | 32 | type LeaveAction = { 33 | type: 'leave' 34 | } 35 | 36 | type ListSessionsAction = { 37 | type: 'listSessions' 38 | } 39 | 40 | export type CallAction = 41 | | { 42 | type: 'call' 43 | to: string 44 | sdp: string 45 | isSFU?: false 46 | } 47 | | { 48 | type: 'call' 49 | sdp: string 50 | isSFU: true 51 | } 52 | 53 | export type AnswerAction = { 54 | type: 'answer' 55 | to: string 56 | sdp: string 57 | isSFU?: boolean 58 | } 59 | 60 | export type CandidateAction = 61 | | { 62 | type: 'candidate' 63 | isSFU: false 64 | to: string 65 | candidate: any 66 | } 67 | | { 68 | type: 'candidate' 69 | isSFU: true 70 | candidate: any 71 | to?: string 72 | } 73 | 74 | export type Message = 75 | | { 76 | type: 'text' 77 | text: string 78 | } 79 | | { 80 | type: 'custom' 81 | data: any 82 | } 83 | 84 | type MessageAction = { 85 | type: 'message' 86 | message: Message 87 | } 88 | 89 | type IntegrateUserAction = { 90 | type: 'integrateUser' 91 | user: { 92 | [key: string]: any 93 | } 94 | } 95 | 96 | export type UpdateMediaStateAction = { 97 | type: 'updateMediaState' 98 | key: 'videoOff' | 'muted' 99 | value: boolean 100 | } 101 | 102 | const actionTypes = [ 103 | 'getId', 104 | 'reuseId', 105 | 'subscribe', 106 | 'unsubscribe', 107 | 'enter', 108 | 'leave', 109 | 'message', 110 | 'listSessions', 111 | 'call', 112 | 'answer', 113 | 'candidate', 114 | 'integrateUser', 115 | 'updateMediaState', 116 | ] 117 | 118 | export type ReceiveAction = 119 | | GetIdAction 120 | | ReuseIdAction 121 | | SubscribeAction 122 | | UnsubscribeAction 123 | | EnterAction 124 | | LeaveAction 125 | | MessageAction 126 | | ListSessionsAction 127 | | CallAction 128 | | AnswerAction 129 | | CandidateAction 130 | | IntegrateUserAction 131 | | UpdateMediaStateAction 132 | 133 | export function isReceiveAction(object: any): object is ReceiveAction { 134 | if (!object?.type) return false 135 | return actionTypes.includes(object.type) 136 | } 137 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/websocket/actions/send.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * actions that server sends 3 | */ 4 | 5 | import { SessionUser } from '../../../services/sessionService' 6 | import { Message } from './receive' 7 | 8 | export type ConnectedAction = { 9 | type: 'connected' 10 | id: string 11 | token: string 12 | } 13 | 14 | export type GetIdSuccessAction = { 15 | type: 'getIdSuccess' 16 | id: string 17 | } 18 | 19 | export type ReuseIdSuccessAction = { 20 | type: 'reuseIdSuccess' 21 | user: any 22 | } 23 | 24 | export type SubscriptionMessageAction = { 25 | type: 'subscriptionMessage' 26 | key: string 27 | message: any 28 | } 29 | 30 | export type SubscriptionSuccess = { 31 | type: 'subscriptionSuccess' 32 | key: string 33 | } 34 | 35 | export type ListSessionsSuccess = { 36 | type: 'listSessionsSuccess' 37 | sessions: { id: string; user: any }[] 38 | } 39 | 40 | export type EnterSuccessAction = { 41 | type: 'enterSuccess' 42 | sfuEnabled: boolean 43 | } 44 | 45 | export type EnteredAction = { 46 | type: 'entered' 47 | sessionId: string 48 | user: SessionUser 49 | } 50 | 51 | export type LeftAction = { 52 | type: 'left' 53 | sessionId: string 54 | } 55 | 56 | export type MessagedAction = { 57 | type: 'messaged' 58 | sessionId: string 59 | message: Message 60 | } 61 | 62 | export type CalledAction = { 63 | type: 'called' 64 | from: string 65 | sdp: string 66 | isSFU?: boolean 67 | } 68 | 69 | export type AnsweredAction = 70 | | { 71 | type: 'answered' 72 | isSFU: false 73 | from: string 74 | sdp: string 75 | } 76 | | { 77 | type: 'answered' 78 | isSFU: true 79 | sdp: string 80 | } 81 | 82 | export type CandidatedAction = 83 | | { 84 | type: 'candidated' 85 | from: string 86 | candidate: any 87 | isSFU: false 88 | } 89 | | { 90 | type: 'candidated' 91 | from?: string 92 | candidate: any 93 | isSFU: true 94 | } 95 | 96 | export type IntegratedUserAction = { 97 | type: 'integrated' 98 | user: { 99 | id: string 100 | [key: string]: any 101 | } 102 | } 103 | 104 | export type ChannelClosedAction = { 105 | type: 'channelClosed' 106 | } 107 | 108 | export type UpdatedMediaStateAction = { 109 | type: 'updatedMediaState' 110 | sessionId: string 111 | key: 'muted' | 'videoOff' 112 | value: boolean 113 | } 114 | 115 | export type SendAction = 116 | | ConnectedAction 117 | | ReuseIdSuccessAction 118 | | SubscriptionMessageAction 119 | | SubscriptionSuccess 120 | | ListSessionsSuccess 121 | | EnteredAction 122 | | LeftAction 123 | | MessagedAction 124 | | CalledAction 125 | | AnsweredAction 126 | | CandidatedAction 127 | | IntegratedUserAction 128 | | EnterSuccessAction 129 | | ChannelClosedAction 130 | | UpdatedMediaStateAction 131 | 132 | const actionCreators = { 133 | connected: (id: string, token: string): ConnectedAction => ({ 134 | type: 'connected', 135 | id, 136 | token, 137 | }), 138 | getIdSuccess: (id: string): GetIdSuccessAction => ({ 139 | type: 'getIdSuccess', 140 | id: id, 141 | }), 142 | reuseIdSuccess: (user: any): ReuseIdSuccessAction => ({ 143 | type: 'reuseIdSuccess', 144 | user, 145 | }), 146 | subscriptionMessage: ( 147 | key: string, 148 | message: any 149 | ): SubscriptionMessageAction => ({ 150 | type: 'subscriptionMessage', 151 | key, 152 | message, 153 | }), 154 | subscriptionSuccess: (key: string): SubscriptionSuccess => ({ 155 | type: 'subscriptionSuccess', 156 | key, 157 | }), 158 | listSessionsSuccess: ( 159 | sessions: { id: string; user: any }[] 160 | ): ListSessionsSuccess => ({ 161 | type: 'listSessionsSuccess', 162 | sessions, 163 | }), 164 | entered: (sessionId: string, user: SessionUser): EnteredAction => ({ 165 | type: 'entered', 166 | sessionId, 167 | user, 168 | }), 169 | left: (sessionId: string): LeftAction => ({ 170 | type: 'left', 171 | sessionId, 172 | }), 173 | messaged: (sessionId: string, message: Message): MessagedAction => ({ 174 | type: 'messaged', 175 | message, 176 | sessionId, 177 | }), 178 | called: (from: string, sdp: string, isSFU?: boolean): CalledAction => ({ 179 | type: 'called', 180 | from, 181 | sdp, 182 | isSFU, 183 | }), 184 | answered: ( 185 | from: string | undefined, 186 | sdp: string, 187 | isSFU?: boolean 188 | ): AnsweredAction => { 189 | if (isSFU) { 190 | return { 191 | type: 'answered', 192 | isSFU, 193 | sdp, 194 | } 195 | } 196 | return { 197 | type: 'answered', 198 | isSFU: false, 199 | from: from!, 200 | sdp, 201 | } 202 | }, 203 | candidated: ( 204 | from: string | undefined, 205 | candidate: any, 206 | isSFU: boolean = false 207 | ): CandidatedAction => { 208 | return isSFU 209 | ? { type: 'candidated', from: from, candidate, isSFU: true } 210 | : { type: 'candidated', from: from!, candidate, isSFU: false } 211 | }, 212 | integrated: (user: { 213 | id: string 214 | [key: string]: any 215 | }): IntegratedUserAction => ({ 216 | type: 'integrated', 217 | user, 218 | }), 219 | enterSuccess: (sfuEnabled: boolean): EnterSuccessAction => ({ 220 | type: 'enterSuccess', 221 | sfuEnabled, 222 | }), 223 | channelClosed: (): ChannelClosedAction => ({ 224 | type: 'channelClosed', 225 | }), 226 | updatedState: ( 227 | sessionId: string, 228 | key: 'muted' | 'videoOff', 229 | value: boolean 230 | ): UpdatedMediaStateAction => ({ 231 | type: 'updatedMediaState', 232 | sessionId, 233 | key, 234 | value, 235 | }), 236 | } 237 | 238 | export default actionCreators 239 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/websocket/channelHelper.ts: -------------------------------------------------------------------------------- 1 | import { Message } from './actions/receive' 2 | import { coreRedisClient, publishJSON } from './redis/createRedisClient' 3 | import { promisify } from 'util' 4 | import prefixer from './redis/prefixer' 5 | import actionCreators from './actions/send' 6 | import { SessionUser } from '../../services/sessionService' 7 | import channelService from '../../services/channelService' 8 | 9 | const channelHelper = { 10 | enter(channel: string, sessionId: string, user: SessionUser) { 11 | publishJSON( 12 | prefixer.channel(channel), 13 | actionCreators.entered(sessionId, user) 14 | ) 15 | channelService.addUser(channel, sessionId) 16 | }, 17 | async leave(channel: string, sessionId: string) { 18 | await publishJSON(prefixer.channel(channel), actionCreators.left(sessionId)) 19 | await channelService.removeUser(sessionId) 20 | }, 21 | message(channel: string, sessionId: string, message: Message) { 22 | publishJSON( 23 | prefixer.channel(channel), 24 | actionCreators.messaged(sessionId, message) 25 | ) 26 | }, 27 | close(channel: string) { 28 | publishJSON(prefixer.channel(channel), actionCreators.channelClosed()) 29 | }, 30 | broadcast(channel: string, message: any) { 31 | publishJSON(prefixer.channel(channel), message) 32 | }, 33 | } 34 | 35 | export default channelHelper 36 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/websocket/redis/createRedisClient.ts: -------------------------------------------------------------------------------- 1 | import redis from 'redis' 2 | import { promisify } from 'util' 3 | 4 | const createRedisClient = () => { 5 | return redis.createClient() 6 | } 7 | 8 | export const coreRedisClient = createRedisClient() 9 | 10 | // this subscriber is used for sessions 11 | export const globalSubscriber = createRedisClient() 12 | 13 | export const localSubscriber = createRedisClient() 14 | 15 | export const publishAsync = promisify(coreRedisClient.publish).bind( 16 | coreRedisClient 17 | ) 18 | export const publishJSON = (channel: string, json: any) => 19 | publishAsync(channel, JSON.stringify(json)) 20 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/websocket/redis/prefixer.ts: -------------------------------------------------------------------------------- 1 | const prefixer = { 2 | channel: (channel: string) => `channel:${channel}`, 3 | sessions: (channel: string) => `${prefixer.channel(channel)}:sessions`, 4 | direct: (sessionId: string) => `direct:${sessionId}`, 5 | } 6 | 7 | export default prefixer 8 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/websocket/redis/subscription.ts: -------------------------------------------------------------------------------- 1 | import Session from '../Session' 2 | import { globalSubscriber } from './createRedisClient' 3 | import { promisify } from 'util' 4 | 5 | class Subscription { 6 | subscriptionMap = new Map>() 7 | 8 | async subscribe(key: string, session: Session) { 9 | const registered = this.subscriptionMap.has(key) 10 | if (!registered) { 11 | try { 12 | await new Promise((resolve, reject) => 13 | globalSubscriber.subscribe(key, (e, reply) => { 14 | if (e) { 15 | reject(e) 16 | return 17 | } 18 | resolve(reply) 19 | }) 20 | ) 21 | } catch (e) { 22 | console.error(`Failed to subscribe to ${key}`) 23 | } 24 | 25 | this.subscriptionMap.set(key, new Set()) 26 | } 27 | const sessionSet = this.subscriptionMap.get(key)! // guaranteed to be valid 28 | sessionSet.add(session) 29 | 30 | return () => { 31 | this.unsubscribe(key, session) 32 | } 33 | } 34 | 35 | unsubscribe(key: string, session: Session) { 36 | const sessionSet = this.subscriptionMap.get(key) 37 | if (!sessionSet) return 38 | sessionSet.delete(session) 39 | 40 | if (sessionSet.size === 0) { 41 | this.subscriptionMap.delete(key) 42 | } 43 | } 44 | 45 | dispatch(key: string, message: any) { 46 | const sessionSet = this.subscriptionMap.get(key) 47 | if (!sessionSet) return 48 | sessionSet.forEach(value => { 49 | value.sendSubscriptionMessage(key, message) 50 | }) 51 | } 52 | } 53 | 54 | const subscription = new Subscription() 55 | 56 | globalSubscriber.on('message', (channel, message) => { 57 | try { 58 | const parsed = JSON.parse(message) 59 | subscription.dispatch(channel, parsed) 60 | } catch (e) { 61 | console.error( 62 | `Failed to parse message from redis subscription "${message}"` 63 | ) 64 | } 65 | }) 66 | 67 | export default subscription 68 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/lib/websocket/rtcHelper.ts: -------------------------------------------------------------------------------- 1 | import { Description } from './actions/common' 2 | import actionCreators from './actions/send' 3 | import { publishJSON } from './redis/createRedisClient' 4 | import prefixer from './redis/prefixer' 5 | 6 | const rtcHelper = { 7 | call({ from, to, sdp }: { from: string; to: string; sdp: string }) { 8 | publishJSON(prefixer.direct(to), actionCreators.called(from, sdp)) 9 | }, 10 | answer({ from, to, sdp }: { from: string; to: string; sdp: string }) { 11 | publishJSON(prefixer.direct(to), actionCreators.answered(from, sdp)) 12 | }, 13 | candidate({ 14 | from, 15 | to, 16 | candidate, 17 | }: { 18 | from: string 19 | to: string 20 | candidate: any 21 | }) { 22 | publishJSON(prefixer.direct(to), actionCreators.candidated(from, candidate)) 23 | }, 24 | } 25 | 26 | export default rtcHelper 27 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/plugins/README.md: -------------------------------------------------------------------------------- 1 | # Plugins Folder 2 | 3 | Plugins define behavior that is common to all the routes in your 4 | application. Authentication, caching, templates, and all the other cross 5 | cutting concerns should be handled by plugins placed in this folder. 6 | 7 | Files in this folder are typically defined through the 8 | [`fastify-plugin`](https://github.com/fastify/fastify-plugin) module, 9 | making them non-encapsulated. They can define decorators and set hooks 10 | that will then be used in the rest of your application. 11 | 12 | Check out: 13 | 14 | * [The hitchhiker's guide to plugins](https://www.fastify.io/docs/latest/Plugins-Guide/) 15 | * [Fastify decorators](https://www.fastify.io/docs/latest/Decorators/). 16 | * [Fastify lifecycle](https://www.fastify.io/docs/latest/Lifecycle/). 17 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/plugins/cors.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import cors from 'fastify-cors' 3 | 4 | export default fp(async (fastify, opts) => { 5 | if (process.env.CORS === 'true') { 6 | fastify.register(cors, { 7 | origin: '*', 8 | }) 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/plugins/sensible.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import sensible, { SensibleOptions } from 'fastify-sensible' 3 | 4 | /** 5 | * This plugins adds some utilities to handle http errors 6 | * 7 | * @see https://github.com/fastify/fastify-sensible 8 | */ 9 | export default fp(async (fastify, opts) => { 10 | fastify.register(sensible, { 11 | errorHandler: false 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/plugins/support.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | 3 | export interface SupportPluginOptions { 4 | // Specify Support plugin options here 5 | } 6 | 7 | // The use of fastify-plugin is required to be able 8 | // to export the decorators to the outer scope 9 | export default fp(async (fastify, opts) => { 10 | fastify.decorate('someSupport', function () { 11 | return 'hugs' 12 | }) 13 | }) 14 | 15 | // When using .decorate you have to specify added properties for Typescript 16 | declare module 'fastify' { 17 | export interface FastifyInstance { 18 | someSupport(): string; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/plugins/swagger.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import swagger from 'fastify-swagger' 3 | 4 | /** 5 | * apply websocket to server 6 | */ 7 | export default fp(async (fastify, opts) => { 8 | fastify.register(swagger, { 9 | routePrefix: '/documentation', 10 | exposeRoute: true, 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/plugins/websocket.ts: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import fastifyWebsocket from 'fastify-websocket' 3 | 4 | /** 5 | * apply websocket to server 6 | */ 7 | export default fp(async (fastify, opts) => { 8 | fastify.register(fastifyWebsocket) 9 | }) 10 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/routes/README.md: -------------------------------------------------------------------------------- 1 | # Routes Folder 2 | 3 | Routes define endpoints within your application. Fastify provides an 4 | easy path to a microservice architecture, in the future you might want 5 | to independently deploy some of those. 6 | 7 | In this folder you should define all the routes that define the endpoints 8 | of your web application. 9 | Each service is a [Fastify 10 | plugin](https://www.fastify.io/docs/latest/Plugins/), it is 11 | encapsulated (it can have its own independent plugins) and it is 12 | typically stored in a file; be careful to group your routes logically, 13 | e.g. all `/users` routes in a `users.js` file. We have added 14 | a `root.js` file for you with a '/' root added. 15 | 16 | If a single file become too large, create a folder and add a `index.js` file there: 17 | this file must be a Fastify plugin, and it will be loaded automatically 18 | by the application. You can now add as many files as you want inside that folder. 19 | In this way you can create complex routes within a single monolith, 20 | and eventually extract them. 21 | 22 | If you need to share functionality between routes, place that 23 | functionality into the `plugins` folder, and share it via 24 | [decorators](https://www.fastify.io/docs/latest/Decorators/). 25 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/routes/channels/index.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from 'fastify' 2 | import channelService from '../../services/channelService' 3 | import CreateChannelBodySchema from '../../schemas/channels/create/body.json' 4 | import { CreateChannelBody } from '../../schema-types/channels/create/body' 5 | import RemoveChannelParamsSchema from '../../schemas/channels/remove/params.json' 6 | import { RemoveChannelParams } from '../../schema-types/channels/remove/params' 7 | import GetChannelParamsSchema from '../../schemas/channels/get/params.json' 8 | import { GetChannelParams } from '../../schema-types/channels/get/params' 9 | import BulkDeleteBodySchema from '../../schemas/channels/get/params.json' 10 | import { BulkDeleteChannelBody } from '../../schema-types/channels/bulk-delete/body' 11 | import protect from '../../lib/plugins/protect' 12 | 13 | const channels: FastifyPluginAsync = async fastify => { 14 | fastify.register(protect) 15 | 16 | // @todo: list all channels 17 | fastify.get( 18 | '/', 19 | { 20 | schema: { 21 | description: 'List all channels', 22 | response: { 23 | 200: { 24 | description: 'Successful response', 25 | type: 'array', 26 | items: { 27 | type: 'object', 28 | properties: { 29 | id: { type: 'string' }, 30 | sfuServerId: { type: 'number', nullable: true }, 31 | sessionCount: { type: 'number' }, 32 | }, 33 | }, 34 | example: [ 35 | { 36 | id: '94ea89b2-c2e1-4e99-817c-47c62dfa7297', 37 | sfuServerId: 1, 38 | sessionCount: 0, 39 | }, 40 | ], 41 | }, 42 | }, 43 | }, 44 | }, 45 | async () => { 46 | const channels = await channelService.listAll() 47 | return channels 48 | } 49 | ) 50 | // add option to list empty ones 51 | 52 | fastify.post<{ Body: CreateChannelBody }>( 53 | '/', 54 | { 55 | schema: { 56 | description: 'Create channel', 57 | querystring: CreateChannelBodySchema, 58 | response: { 59 | 200: { 60 | description: 'Successful response', 61 | type: 'object', 62 | properties: { 63 | id: { 64 | type: 'string', 65 | }, 66 | sfuServerId: { 67 | type: 'number', 68 | nullable: true, 69 | }, 70 | }, 71 | example: { 72 | id: 'c7fcff7d-4dd6-4e43-b4ae-18b1138a8216', 73 | sfuServerId: 1, 74 | }, 75 | }, 76 | }, 77 | }, 78 | }, 79 | async request => { 80 | return channelService.create(!!request.body.sfuEnabled) 81 | } 82 | ) 83 | 84 | fastify.get<{ Params: GetChannelParams }>( 85 | '/:id', 86 | { 87 | schema: { 88 | params: GetChannelParamsSchema, 89 | }, 90 | }, 91 | async (request, reply) => { 92 | return channelService.getChannelInfo(request.params.id) 93 | } 94 | ) 95 | 96 | fastify.post<{ Body: BulkDeleteChannelBody }>( 97 | '/bulk-delete', 98 | { 99 | schema: { 100 | body: BulkDeleteBodySchema, 101 | response: { 102 | 204: { 103 | description: 'Successful response', 104 | type: 'null', 105 | example: null, 106 | }, 107 | }, 108 | }, 109 | }, 110 | async request => { 111 | const ids = request.body.ids 112 | for (let i = 0; i < ids.length; i++) { 113 | await channelService.remove(ids[i]) 114 | } 115 | } 116 | ) 117 | 118 | fastify.delete<{ Params: RemoveChannelParams }>( 119 | '/:id', 120 | { 121 | schema: { 122 | params: RemoveChannelParamsSchema, 123 | response: { 124 | 204: { 125 | description: 'Successful response', 126 | type: 'null', 127 | example: null, 128 | }, 129 | }, 130 | }, 131 | }, 132 | async (request, reply) => { 133 | await channelService.remove(request.params.id) 134 | reply.status(204) 135 | } 136 | ) 137 | } 138 | export default channels 139 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/routes/root.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from 'fastify' 2 | 3 | const root: FastifyPluginAsync = async (fastify, opts): Promise => { 4 | fastify.get('/', async function (request, reply) { 5 | return { root: true } 6 | }) 7 | } 8 | 9 | export default root; 10 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/routes/sessions/index.ts: -------------------------------------------------------------------------------- 1 | import fastify, { FastifyPluginAsync } from 'fastify' 2 | import { IntegrateSessionParams } from '../../schema-types/sessions/integrate/params' 3 | import IntegrateSessionParamsSchema from '../../schemas/sessions/integrate/params.json' 4 | import { IntegrateSessionBody } from '../../schema-types/sessions/integrate/body' 5 | import IntegrateSessionBodySchema from '../../schemas/sessions/integrate/body.json' 6 | import sessionService from '../../services/sessionService' 7 | import protect from '../../lib/plugins/protect' 8 | 9 | const sessions: FastifyPluginAsync = async fastify => { 10 | fastify.register(protect) 11 | fastify.post<{ Params: IntegrateSessionParams; Body: IntegrateSessionBody }>( 12 | '/:id', 13 | { 14 | schema: { 15 | description: 16 | 'Integrate user data to session.\nUse this API to create or update the session.', 17 | params: IntegrateSessionParamsSchema, 18 | body: IntegrateSessionBodySchema, 19 | }, 20 | }, 21 | async request => { 22 | const { id } = request.params 23 | const { body } = request 24 | const userJSONString = JSON.stringify(body) 25 | return sessionService.integrate(id, userJSONString) 26 | } 27 | ) 28 | } 29 | 30 | export default sessions 31 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/routes/sfu-servers/index.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from 'fastify' 2 | import protect from '../../lib/plugins/protect' 3 | import { CreateSFUServerBody } from '../../schema-types/sfu-servers/create/body' 4 | import CreateSFUServerBodySchema from '../../schemas/sfu-servers/create/body.json' 5 | import sfuServerService from '../../services/sfuServerService' 6 | 7 | const sfuServers: FastifyPluginAsync = async fastify => { 8 | fastify.register(protect) 9 | 10 | fastify.get('/', async () => { 11 | return sfuServerService.listWithStats() 12 | }) 13 | 14 | fastify.post<{ 15 | Body: CreateSFUServerBody 16 | }>( 17 | '/', 18 | { 19 | schema: { 20 | body: CreateSFUServerBodySchema, 21 | }, 22 | }, 23 | async request => { 24 | return sfuServerService.create(request.body.address) 25 | } 26 | ) 27 | 28 | fastify.delete<{ Params: { id: string } }>( 29 | '/:id', 30 | { 31 | schema: { 32 | params: { 33 | type: 'object', 34 | properties: { 35 | id: { 36 | type: 'string', 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | async (request, reply) => { 43 | try { 44 | await sfuServerService.delete(parseInt(request.params.id, 10)) 45 | } catch (e) {} 46 | reply.status(204) 47 | } 48 | ) 49 | 50 | fastify.patch<{ Params: { id: string }; Body: { disabled: boolean } }>( 51 | '/:id', 52 | async (request, reply) => { 53 | return sfuServerService.updateState( 54 | parseInt(request.params.id, 10), 55 | request.body.disabled 56 | ) 57 | } 58 | ) 59 | } 60 | 61 | export default sfuServers 62 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/routes/websocket/index.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from 'fastify' 2 | import path from 'path' 3 | import { isReceiveAction } from '../../lib/websocket/actions/receive' 4 | import fs from 'fs' 5 | 6 | import Session from '../../lib/websocket/Session' 7 | import prisma from '../../lib/prisma' 8 | import channelHelper from '../../lib/websocket/channelHelper' 9 | import { getClosing } from '../../lib/close' 10 | 11 | const sessions = new Set() 12 | const websocket: FastifyPluginAsync = async fastify => { 13 | fastify.get('/', { websocket: true }, (connection, req) => { 14 | if (getClosing()) { 15 | connection.socket.close() 16 | return 17 | } 18 | const session = new Session(connection.socket) 19 | 20 | addSessionId(session.id) 21 | sessions.add(session) 22 | connection.socket.on('message', message => { 23 | // console.log(message) 24 | 25 | try { 26 | const data = JSON.parse(message.toString()) 27 | if (!isReceiveAction(data)) return 28 | session.handle(data) 29 | } catch (e) { 30 | console.error(e) 31 | } 32 | }) 33 | 34 | connection.socket.on('close', async (code, reason) => { 35 | sessions.delete(session) 36 | await session.dispose() 37 | await removeSessionId(session.id) 38 | }) 39 | }) 40 | } 41 | 42 | const sessionIdsPath = path.resolve(process.cwd(), '.sessionIds') 43 | 44 | const sessionIds = new Set() 45 | 46 | function syncSessionIds() { 47 | const sessionIdsArray = [...sessionIds] 48 | 49 | return new Promise(resolve => 50 | fs.writeFile(sessionIdsPath, sessionIdsArray.join(','), 'utf-8', err => { 51 | resolve() 52 | }) 53 | ) 54 | } 55 | 56 | function addSessionId(sessionId: string) { 57 | sessionIds.add(sessionId) 58 | return syncSessionIds() 59 | } 60 | 61 | function removeSessionId(sessionId: string) { 62 | sessionIds.delete(sessionId) 63 | return syncSessionIds() 64 | } 65 | 66 | async function cleanSession(id: string) { 67 | const s = await prisma.channelSession.findFirst({ 68 | where: { 69 | sessionId: id, 70 | }, 71 | }) 72 | if (!s) return 73 | channelHelper.leave(s.channelId, id) 74 | } 75 | 76 | export function cleanSessions() { 77 | // load sessionIds from file 78 | try { 79 | const sessionIds = fs.readFileSync(sessionIdsPath, 'utf-8').split(',') 80 | return Promise.all(sessionIds.map(cleanSession)) 81 | } catch (e) { 82 | return Promise.resolve([]) 83 | } 84 | } 85 | 86 | export function disconnectAllSessions() { 87 | return Promise.all([...sessions].map(s => s.dispose())) 88 | } 89 | 90 | export default websocket 91 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schema-types/channels/bulk-delete/body.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | export interface BulkDeleteChannelBody { 9 | ids: string[]; 10 | [k: string]: unknown; 11 | } 12 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schema-types/channels/create/body.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | export interface CreateChannelBody { 9 | /** 10 | * true or omit this value 11 | */ 12 | sfuEnabled?: boolean; 13 | [k: string]: unknown; 14 | } 15 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schema-types/channels/get/params.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | export interface GetChannelParams { 9 | /** 10 | * Channel ID 11 | */ 12 | id: string; 13 | [k: string]: unknown; 14 | } 15 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schema-types/channels/remove/body.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | export interface RemoveChannelBody { 9 | /** 10 | * channel id 11 | */ 12 | id?: string; 13 | [k: string]: unknown; 14 | } 15 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schema-types/channels/remove/params.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | export interface RemoveChannelParams { 9 | /** 10 | * Channel ID 11 | */ 12 | id: string; 13 | [k: string]: unknown; 14 | } 15 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schema-types/sessions/integrate/body.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | /** 9 | * You can put any extra fields of user data in body 10 | */ 11 | export interface IntegrateSessionBody { 12 | /** 13 | * Unique id of user 14 | */ 15 | id: string; 16 | [k: string]: unknown; 17 | } 18 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schema-types/sessions/integrate/params.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | export interface IntegrateSessionParams { 9 | /** 10 | * Session ID 11 | */ 12 | id: string; 13 | [k: string]: unknown; 14 | } 15 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schema-types/sfu-servers/create/body.d.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /** 3 | * This file was automatically generated by json-schema-to-typescript. 4 | * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, 5 | * and run json-schema-to-typescript to regenerate this file. 6 | */ 7 | 8 | export interface CreateSFUServerBody { 9 | address: string; 10 | [k: string]: unknown; 11 | } 12 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schemas/channels/bulk-delete/body.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Bulk Delete Channel Body", 3 | "type": "object", 4 | "properties": { 5 | "ids": { 6 | "type": "array", 7 | "items": { 8 | "type": "string" 9 | } 10 | } 11 | }, 12 | "required": ["ids"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schemas/channels/create/body.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Create Channel Body", 3 | "type": "object", 4 | "properties": { 5 | "sfuEnabled": { 6 | "type": "boolean", 7 | "description": "true or omit this value" 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schemas/channels/get/params.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Get Channel Params", 3 | "type": "object", 4 | "properties": { 5 | "id": { 6 | "type": "string", 7 | "description": "Channel ID" 8 | } 9 | }, 10 | "required": ["id"] 11 | } -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schemas/channels/remove/params.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Remove Channel Params", 3 | "type": "object", 4 | "properties": { 5 | "id": { 6 | "type": "string", 7 | "description": "Channel ID" 8 | } 9 | }, 10 | "required": ["id"] 11 | } -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schemas/sessions/integrate/body.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Integrate Session Body", 3 | "type": "object", 4 | "properties": { 5 | "id": { 6 | "type": "string", 7 | "description": "Unique id of user" 8 | } 9 | }, 10 | "description": "You can put any extra fields of user data in body", 11 | "required": ["id"] 12 | } -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schemas/sessions/integrate/params.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Integrate Session Params", 3 | "type": "object", 4 | "properties": { 5 | "id": { 6 | "type": "string", 7 | "description": "Session ID" 8 | } 9 | }, 10 | "required": ["id"] 11 | } -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/schemas/sfu-servers/create/body.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Create SFU Server Body", 3 | "type": "object", 4 | "properties": { 5 | "address": { 6 | "type": "string" 7 | } 8 | }, 9 | "required": ["address"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/server.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // Read the .env file. 4 | import * as dotenv from 'dotenv' 5 | dotenv.config() 6 | 7 | // Require the framework 8 | import Fastify, { fastify } from 'fastify' 9 | import { startClosing } from './lib/close' 10 | import { disconnectAllSessions } from './routes/websocket' 11 | 12 | // Instantiate Fastify with some config 13 | const app = Fastify({ 14 | logger: true, 15 | }) 16 | 17 | // Register your application as a normal plugin. 18 | app.register(import('./app')) 19 | 20 | process.on('SIGINT', async () => { 21 | startClosing() 22 | await disconnectAllSessions() 23 | app.close() 24 | setTimeout(() => { 25 | console.log('Server is now closed') 26 | process.exit(0) 27 | }, 1000) 28 | }) 29 | 30 | // delay is the number of milliseconds for the graceful close to finish 31 | // const closeListeners = closeWithGrace( 32 | // { delay: 500 }, 33 | // async function ({ signal, err, manual }) { 34 | // if (err) { 35 | // app.log.error(err) 36 | // } 37 | // await app.close() 38 | // } 39 | // ) 40 | 41 | // app.addHook('onClose', async (instance, done) => { 42 | // closeListeners.uninstall() 43 | // done() 44 | // }) 45 | 46 | // Start listening. 47 | app.listen(process.env.PORT || 3000, (err: any) => { 48 | if (err) { 49 | app.log.error(err) 50 | process.exit(1) 51 | } 52 | }) 53 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/services/channelService.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid' 2 | import { MooyahoError } from '../lib/MooyahoError' 3 | import prisma from '../lib/prisma' 4 | import channelHelper from '../lib/websocket/channelHelper' 5 | import sfuServerService from './sfuServerService' 6 | 7 | const channelService = { 8 | async getChannelInfo(id: string) { 9 | const channel = await prisma.channel.findUnique({ 10 | where: { 11 | id, 12 | }, 13 | include: { 14 | ChannelSessions: { 15 | include: { 16 | session: { 17 | include: { 18 | user: true, 19 | }, 20 | }, 21 | }, 22 | }, 23 | }, 24 | }) 25 | 26 | if (!channel) { 27 | throw new MooyahoError({ 28 | name: 'Not Found', 29 | statusCode: 404, 30 | message: 'Channel is not found', 31 | }) 32 | } 33 | 34 | const channelSessions = channel.ChannelSessions.map(cs => ({ 35 | sessionId: cs.sessionId, 36 | user: JSON.parse(cs.session.user.json), 37 | })) 38 | 39 | return { 40 | id: channel.id, 41 | sfuServerId: channel.sfuServerId, 42 | channelSessions, 43 | } 44 | }, 45 | async create(sfuEnabled: boolean) { 46 | const id = v4() 47 | const sfuServerid = sfuEnabled 48 | ? await sfuServerService.getNextSFUServerId() 49 | : undefined 50 | 51 | const channel = await prisma.channel.create({ 52 | data: { 53 | id, 54 | sfuServerId: sfuServerid, 55 | }, 56 | }) 57 | 58 | return channel 59 | }, 60 | 61 | async remove(id: string) { 62 | channelHelper.close(id) 63 | return Promise.all([ 64 | prisma.channelSession.deleteMany({ 65 | where: { 66 | channelId: id, 67 | }, 68 | }), 69 | prisma.channel.delete({ 70 | where: { 71 | id, 72 | }, 73 | }), 74 | ]) 75 | }, 76 | 77 | async findById(id: string) { 78 | return prisma.channel.findUnique({ 79 | where: { 80 | id, 81 | }, 82 | }) 83 | }, 84 | 85 | async addUser(channelId: string, sessionId: string) { 86 | const sessionUser = await prisma.channelSession.create({ 87 | data: { 88 | channelId, 89 | sessionId, 90 | }, 91 | }) 92 | return sessionUser 93 | }, 94 | 95 | async removeUser(sessionId: string) { 96 | return prisma.channelSession.deleteMany({ 97 | where: { 98 | sessionId, 99 | }, 100 | }) 101 | }, 102 | 103 | async listUsers(channelId: string) { 104 | const channelSessions = await prisma.channelSession.findMany({ 105 | where: { 106 | channelId, 107 | }, 108 | include: { 109 | session: { 110 | include: { 111 | user: true, 112 | }, 113 | }, 114 | }, 115 | }) 116 | const session = channelSessions.map(cs => ({ 117 | id: cs.sessionId, 118 | user: JSON.parse(cs.session.user.json), 119 | state: JSON.parse(cs.session.state), 120 | })) 121 | return session 122 | }, 123 | 124 | async listAll() { 125 | /* @todo: Use aggregate group 126 | https://www.prisma.io/docs/concepts/components/prisma-client/aggregation-grouping-summarizing#order-by-aggregate-group-preview 127 | */ 128 | 129 | const channels = await prisma.channel.findMany({ 130 | include: { 131 | ChannelSessions: true, 132 | }, 133 | }) 134 | 135 | return channels.map(({ id, sfuServerId, ChannelSessions }) => ({ 136 | id, 137 | sfuServerId, 138 | sessionCount: ChannelSessions.length, 139 | })) 140 | }, 141 | } 142 | 143 | export default channelService 144 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/services/sessionService.ts: -------------------------------------------------------------------------------- 1 | import prisma from '../lib/prisma' 2 | 3 | const sessionService = { 4 | async integrate(sessionId: string, userJSONString: string) { 5 | const parsed = JSON.parse(userJSONString) 6 | if (parsed.id === undefined) { 7 | const e = new Error('There is no id field in user json') 8 | throw e 9 | } 10 | // 사용자 존재 여부 11 | let user = await prisma.user.findUnique({ 12 | where: { 13 | id: parsed.id, 14 | }, 15 | }) 16 | // 있으면 업데이트하고, 없으면 새로 생성 17 | if (user) { 18 | await prisma.user.update({ 19 | where: { 20 | id: parsed.id, 21 | }, 22 | data: { 23 | json: userJSONString, 24 | }, 25 | }) 26 | } else { 27 | user = await prisma.user.create({ 28 | data: { 29 | id: parsed.id, 30 | json: userJSONString, 31 | }, 32 | }) 33 | } 34 | 35 | let session = await prisma.session.findUnique({ where: { id: sessionId } }) 36 | if (!session) { 37 | session = await prisma.session.create({ 38 | data: { 39 | id: sessionId, 40 | userId: user.id, 41 | state: JSON.stringify({ 42 | muted: false, 43 | videoOff: false, 44 | }), 45 | }, 46 | }) 47 | } 48 | 49 | return { 50 | sessionId: session.id, 51 | user: parsed, 52 | } 53 | }, 54 | 55 | async reintegrate(sessionId: string, prevSessionId: string) { 56 | const prevSession = await prisma.session.findUnique({ 57 | where: { id: prevSessionId }, 58 | include: { 59 | user: true, 60 | }, 61 | }) 62 | if (!prevSession) return false 63 | await prisma.session.create({ 64 | data: { 65 | id: sessionId, 66 | userId: prevSession.userId, 67 | state: JSON.stringify({ 68 | muted: false, 69 | videoOff: false, 70 | }), 71 | }, 72 | }) 73 | return JSON.parse(prevSession.user.json) 74 | }, 75 | 76 | async getUserBySessionId(sessionId: string) { 77 | const session = await prisma.session.findUnique({ 78 | where: { id: sessionId }, 79 | include: { 80 | user: true, 81 | }, 82 | }) 83 | if (!session) { 84 | return null 85 | } 86 | const parsed: SessionUser = JSON.parse(session.user.json) 87 | return parsed 88 | }, 89 | 90 | async updateState(sessionId: string, key: string, value: any) { 91 | const session = await prisma.session.findUnique({ 92 | where: { id: sessionId }, 93 | }) 94 | if (!session) return 95 | const prevState = JSON.parse(session.state) 96 | const nextState = JSON.stringify({ 97 | ...prevState, 98 | [key]: value, 99 | }) 100 | await prisma.session.update({ 101 | where: { id: sessionId }, 102 | data: { 103 | state: nextState, 104 | }, 105 | }) 106 | }, 107 | } 108 | 109 | export type SessionUser = { id: string; [key: string]: any } 110 | 111 | export default sessionService 112 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/services/sfuServerService.ts: -------------------------------------------------------------------------------- 1 | import { SfuServer } from '@prisma/client' 2 | import { create } from 'domain' 3 | import prisma from '../lib/prisma' 4 | import { 5 | localSubscriber, 6 | publishAsync, 7 | } from '../lib/websocket/redis/createRedisClient' 8 | 9 | const sfuServerService = { 10 | async list() { 11 | return await prisma.sfuServer.findMany({ 12 | orderBy: { 13 | id: 'asc', 14 | }, 15 | }) 16 | }, 17 | 18 | async listWithStats() { 19 | const list = await this.list() 20 | const stats: { sfuServerId: number; sessions: number }[] = 21 | await prisma.$queryRaw(`SELECT c.sfuServerId, COUNT(*) as sessions from ChannelSession cs 22 | inner join Channel c on cs.channelId = c.id 23 | group by c.sfuServerId 24 | order by sessions asc 25 | `) 26 | // convert statsArray to Map 27 | const statsMap = new Map() 28 | stats.forEach(({ sfuServerId, sessions }) => { 29 | statsMap.set(sfuServerId, sessions) 30 | }) 31 | 32 | // return list with joined stats 33 | const listWithStats = list.map(s => ({ 34 | ...s, 35 | sessions: statsMap.get(s.id) ?? 0, 36 | })) 37 | 38 | // sort listWithStats by sessions and return 39 | return listWithStats.sort((a, b) => a.sessions - b.sessions) 40 | }, 41 | 42 | async getNextSFUServerId() { 43 | const listWithStats = await this.listWithStats() 44 | return listWithStats.filter(server => !server.disabled)[0]?.id 45 | }, 46 | 47 | async create(address: string) { 48 | const sfuServer = await prisma.sfuServer.create({ 49 | data: { 50 | address, 51 | }, 52 | }) 53 | 54 | publishAsync('sfu_created', sfuServer.id.toString()) 55 | return sfuServer 56 | }, 57 | 58 | async updateState(id: number, disabled: boolean) { 59 | const sfuServer = await prisma.sfuServer.update({ 60 | where: { 61 | id, 62 | }, 63 | data: { 64 | disabled, 65 | }, 66 | }) 67 | return sfuServer 68 | }, 69 | 70 | async delete(id: number, force?: boolean) { 71 | // @todo: check if there are sessions on this sfuServer 72 | 73 | await prisma.sfuServer.delete({ 74 | where: { 75 | id, 76 | }, 77 | }) 78 | return true 79 | }, 80 | 81 | async findSFUServerOf(channelId: string) { 82 | const channel = await prisma.channel.findUnique({ 83 | where: { 84 | id: channelId, 85 | }, 86 | include: { 87 | sfuServer: true, 88 | }, 89 | }) 90 | 91 | return channel?.sfuServer ?? null 92 | }, 93 | 94 | async getSFUServerById(id: number) { 95 | const sfuServer = await prisma.sfuServer.findUnique({ 96 | where: { 97 | id, 98 | }, 99 | }) 100 | return sfuServer 101 | }, 102 | } 103 | 104 | export default sfuServerService 105 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/types/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | NODE_ENV: 'development' | 'production' 5 | PORT?: string 6 | SESSION_SECRET_KEY?: string 7 | API_KEY?: string 8 | CORS: 'true' | 'false' 9 | } 10 | } 11 | } 12 | 13 | // If this file has no import/export statements (i.e. is a script) 14 | // convert it into a module by adding an empty export statement. 15 | export {} 16 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/src/types/missing.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@dnlup/fastify-traps' 2 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/test/helper.ts: -------------------------------------------------------------------------------- 1 | // This file contains code that we reuse between our tests. 2 | import Fastify from 'fastify' 3 | import fp from 'fastify-plugin' 4 | import App from '../src/app' 5 | import * as tap from 'tap'; 6 | 7 | export type Test = typeof tap['Test']['prototype']; 8 | 9 | // Fill in this config with all the configurations 10 | // needed for testing the application 11 | async function config () { 12 | return {} 13 | } 14 | 15 | // Automatically build and tear down our instance 16 | async function build (t: Test) { 17 | const app = Fastify() 18 | 19 | // fastify-plugin ensures that all decorators 20 | // are exposed for testing purposes, this is 21 | // different from the production setup 22 | void app.register(fp(App), await config()) 23 | 24 | await app.ready(); 25 | 26 | // Tear down our app after we are done 27 | t.tearDown(() => void app.close()) 28 | 29 | return app 30 | } 31 | 32 | export { 33 | config, 34 | build 35 | } 36 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/test/plugins/support.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'tap' 2 | import Fastify from 'fastify' 3 | import Support from '../../src/plugins/support' 4 | 5 | test('support works standalone', async (t) => { 6 | const fastify = Fastify() 7 | void fastify.register(Support) 8 | await fastify.ready() 9 | 10 | t.equal(fastify.someSupport(), 'hugs') 11 | }) 12 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/test/routes/example.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'tap' 2 | import { build } from '../helper' 3 | 4 | test('example is loaded', async (t) => { 5 | const app = await build(t) 6 | 7 | const res = await app.inject({ 8 | url: '/example' 9 | }) 10 | 11 | t.equal(res.payload, 'this is an example') 12 | }) 13 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/test/routes/root.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'tap' 2 | import { build } from '../helper' 3 | 4 | test('default root route', async (t) => { 5 | const app = await build(t) 6 | 7 | const res = await app.inject({ 8 | url: '/' 9 | }) 10 | t.deepEqual(JSON.parse(res.payload), { root: true }) 11 | }) 12 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/test/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "noEmit": true 6 | }, 7 | "include": [ 8 | "**/*.ts" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /packages/mooyaho-engine/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "fastify-tsconfig", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "esModuleInterop": true, 6 | "noUnusedLocals": false, 7 | "noUnusedParameters": false 8 | }, 9 | "include": ["src/**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/mooyaho-grpc/.gitignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /packages/mooyaho-grpc/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 80, 6 | "arrowParens": "avoid" 7 | } -------------------------------------------------------------------------------- /packages/mooyaho-grpc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mooyaho/grpc", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@grpc/grpc-js": "^1.3.2", 9 | "@grpc/proto-loader": "^0.6.2", 10 | "@types/google-protobuf": "^3.15.2", 11 | "google-protobuf": "^3.17.3", 12 | "grpc": "^1.24.10", 13 | "grpc-tools": "^1.11.1" 14 | }, 15 | "scripts": { 16 | "build:protos:types": "proto-loader-gen-types --longs=String --enums=String --defaults --oneofs --grpcLib=@grpc/grpc-js --outDir=src/protos/ protos/*.proto", 17 | "build": "tsc" 18 | }, 19 | "files": [ 20 | "/dist", 21 | "/protos" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /packages/mooyaho-grpc/protos/mooyaho.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package mooyaho; 4 | 5 | service Mooyaho { 6 | rpc Call(Signal) returns (Signal) {} 7 | rpc ClientIcecandidate(Signal) returns (Empty) {} 8 | rpc ListenSignal(Empty) returns (stream Signal) {} 9 | rpc Answer(Signal) returns (Empty) {} 10 | rpc Leave(LeaveParams) returns (Empty) {} 11 | } 12 | 13 | message Signal { 14 | string type = 1; // offer, answer, icecandidate 15 | string sessionId = 2; 16 | string sdp = 3; // for offer / answer 17 | string candidate = 4; // for icecandidate 18 | string channelId = 5; 19 | string fromSessionId = 6; 20 | } 21 | 22 | message LeaveParams { 23 | string channelId = 1; 24 | string sessionId = 2; 25 | } 26 | 27 | message Empty {} -------------------------------------------------------------------------------- /packages/mooyaho-grpc/src/Client.ts: -------------------------------------------------------------------------------- 1 | import * as grpc from '@grpc/grpc-js' 2 | import proto from '.' 3 | import { MooyahoClient } from './protos/mooyaho/Mooyaho' 4 | import { promisify } from 'util' 5 | import { Signal } from './protos/mooyaho/Signal' 6 | 7 | export class Client { 8 | client: MooyahoClient 9 | constructor(address: string) { 10 | this.client = new proto.mooyaho.Mooyaho( 11 | address, 12 | grpc.credentials.createInsecure() 13 | ) 14 | } 15 | 16 | async call({ sessionId, sdp, channelId }: CallParams) { 17 | const callAsync = promisify(this.client.call).bind(this.client) 18 | const res = await callAsync({ 19 | sessionId, 20 | sdp, 21 | channelId, 22 | }) 23 | return res!.sdp 24 | } 25 | 26 | async clientIcecandidate({ 27 | fromSessionId, 28 | sessionId, 29 | candidate, 30 | }: ClientIcecandidateParams) { 31 | this.client.clientIcecandidate( 32 | { 33 | fromSessionId, 34 | sessionId, 35 | candidate, 36 | }, 37 | () => {} 38 | ) 39 | } 40 | 41 | async listenSignal(callback: (signal: CallbackSignal) => void) { 42 | const call = this.client.listenSignal({}) 43 | 44 | return new Promise((resolve, reject) => { 45 | call.on('data', (signal: Signal) => { 46 | callback({ 47 | type: signal.type as any, 48 | candidate: signal.candidate!, 49 | sessionId: signal.sessionId!, 50 | fromSessionId: signal.fromSessionId, 51 | sdp: signal.sdp, 52 | }) 53 | }) 54 | call.on('error', e => { 55 | reject(e) 56 | }) 57 | call.on('close', () => { 58 | resolve() 59 | }) 60 | }) 61 | } 62 | 63 | async answer({ sessionId, sdp, channelId, fromSessionId }: AnswerParams) { 64 | const answerAsync = promisify(this.client.answer).bind(this.client) 65 | await answerAsync({ 66 | sessionId, 67 | sdp, 68 | channelId, 69 | fromSessionId, 70 | }) 71 | return true 72 | } 73 | 74 | async leave({ sessionId, channelId }: LeaveParams) { 75 | const leaveAsync = promisify(this.client.leave).bind(this.client) 76 | await leaveAsync({ sessionId, channelId }) 77 | return true 78 | } 79 | } 80 | 81 | type CallParams = { 82 | sessionId: string 83 | sdp: string 84 | channelId: string 85 | } 86 | 87 | type AnswerParams = { 88 | sessionId: string 89 | sdp: string 90 | channelId: string 91 | fromSessionId: string 92 | } 93 | 94 | type ClientIcecandidateParams = { 95 | fromSessionId: string 96 | sessionId?: string 97 | candidate: string 98 | } 99 | 100 | type CallbackSignal = 101 | | { 102 | type: 'icecandidate' 103 | sessionId: string 104 | candidate: string 105 | fromSessionId?: string 106 | } 107 | | { 108 | type: 'offer' 109 | sessionId: string 110 | sdp: string 111 | fromSessionId: string 112 | } 113 | 114 | type LeaveParams = { 115 | channelId: string 116 | sessionId: string 117 | } 118 | -------------------------------------------------------------------------------- /packages/mooyaho-grpc/src/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import * as protoLoader from '@grpc/proto-loader' 3 | import * as grpc from '@grpc/grpc-js' 4 | import { ProtoGrpcType } from './protos/mooyaho' 5 | 6 | const packageDef = protoLoader.loadSync( 7 | path.join(__dirname, '../protos/mooyaho.proto') 8 | ) 9 | const proto: ProtoGrpcType = grpc.loadPackageDefinition(packageDef) as any 10 | 11 | export * from './protos/mooyaho/Mooyaho' 12 | export * from './protos/mooyaho/Signal' 13 | export * from './Client' 14 | export default proto 15 | -------------------------------------------------------------------------------- /packages/mooyaho-grpc/src/protos/mooyaho.ts: -------------------------------------------------------------------------------- 1 | import type * as grpc from '@grpc/grpc-js'; 2 | import type { ServiceDefinition, EnumTypeDefinition, MessageTypeDefinition } from '@grpc/proto-loader'; 3 | 4 | import type { MooyahoClient as _mooyaho_MooyahoClient, MooyahoDefinition as _mooyaho_MooyahoDefinition } from './mooyaho/Mooyaho'; 5 | 6 | type SubtypeConstructor any, Subtype> = { 7 | new(...args: ConstructorParameters): Subtype; 8 | }; 9 | 10 | export interface ProtoGrpcType { 11 | mooyaho: { 12 | Empty: MessageTypeDefinition 13 | LeaveParams: MessageTypeDefinition 14 | Mooyaho: SubtypeConstructor & { service: _mooyaho_MooyahoDefinition } 15 | Signal: MessageTypeDefinition 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /packages/mooyaho-grpc/src/protos/mooyaho/Empty.ts: -------------------------------------------------------------------------------- 1 | // Original file: protos/mooyaho.proto 2 | 3 | 4 | export interface Empty { 5 | } 6 | 7 | export interface Empty__Output { 8 | } 9 | -------------------------------------------------------------------------------- /packages/mooyaho-grpc/src/protos/mooyaho/Leave.ts: -------------------------------------------------------------------------------- 1 | // Original file: protos/mooyaho.proto 2 | 3 | 4 | export interface Leave { 5 | 'channelId'?: (string); 6 | 'sessionId'?: (string); 7 | } 8 | 9 | export interface Leave__Output { 10 | 'channelId': (string); 11 | 'sessionId': (string); 12 | } 13 | -------------------------------------------------------------------------------- /packages/mooyaho-grpc/src/protos/mooyaho/LeaveParams.ts: -------------------------------------------------------------------------------- 1 | // Original file: protos/mooyaho.proto 2 | 3 | 4 | export interface LeaveParams { 5 | 'channelId'?: (string); 6 | 'sessionId'?: (string); 7 | } 8 | 9 | export interface LeaveParams__Output { 10 | 'channelId': (string); 11 | 'sessionId': (string); 12 | } 13 | -------------------------------------------------------------------------------- /packages/mooyaho-grpc/src/protos/mooyaho/ListenSignalRequest.ts: -------------------------------------------------------------------------------- 1 | // Original file: protos/mooyaho.proto 2 | 3 | 4 | export interface ListenSignalRequest { 5 | } 6 | 7 | export interface ListenSignalRequest__Output { 8 | } 9 | -------------------------------------------------------------------------------- /packages/mooyaho-grpc/src/protos/mooyaho/Mooyaho.ts: -------------------------------------------------------------------------------- 1 | // Original file: protos/mooyaho.proto 2 | 3 | import type * as grpc from '@grpc/grpc-js' 4 | import type { MethodDefinition } from '@grpc/proto-loader' 5 | import type { Empty as _mooyaho_Empty, Empty__Output as _mooyaho_Empty__Output } from '../mooyaho/Empty'; 6 | import type { LeaveParams as _mooyaho_LeaveParams, LeaveParams__Output as _mooyaho_LeaveParams__Output } from '../mooyaho/LeaveParams'; 7 | import type { Signal as _mooyaho_Signal, Signal__Output as _mooyaho_Signal__Output } from '../mooyaho/Signal'; 8 | 9 | export interface MooyahoClient extends grpc.Client { 10 | Answer(argument: _mooyaho_Signal, metadata: grpc.Metadata, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 11 | Answer(argument: _mooyaho_Signal, metadata: grpc.Metadata, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 12 | Answer(argument: _mooyaho_Signal, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 13 | Answer(argument: _mooyaho_Signal, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 14 | answer(argument: _mooyaho_Signal, metadata: grpc.Metadata, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 15 | answer(argument: _mooyaho_Signal, metadata: grpc.Metadata, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 16 | answer(argument: _mooyaho_Signal, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 17 | answer(argument: _mooyaho_Signal, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 18 | 19 | Call(argument: _mooyaho_Signal, metadata: grpc.Metadata, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Signal__Output) => void): grpc.ClientUnaryCall; 20 | Call(argument: _mooyaho_Signal, metadata: grpc.Metadata, callback: (error?: grpc.ServiceError, result?: _mooyaho_Signal__Output) => void): grpc.ClientUnaryCall; 21 | Call(argument: _mooyaho_Signal, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Signal__Output) => void): grpc.ClientUnaryCall; 22 | Call(argument: _mooyaho_Signal, callback: (error?: grpc.ServiceError, result?: _mooyaho_Signal__Output) => void): grpc.ClientUnaryCall; 23 | call(argument: _mooyaho_Signal, metadata: grpc.Metadata, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Signal__Output) => void): grpc.ClientUnaryCall; 24 | call(argument: _mooyaho_Signal, metadata: grpc.Metadata, callback: (error?: grpc.ServiceError, result?: _mooyaho_Signal__Output) => void): grpc.ClientUnaryCall; 25 | call(argument: _mooyaho_Signal, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Signal__Output) => void): grpc.ClientUnaryCall; 26 | call(argument: _mooyaho_Signal, callback: (error?: grpc.ServiceError, result?: _mooyaho_Signal__Output) => void): grpc.ClientUnaryCall; 27 | 28 | ClientIcecandidate(argument: _mooyaho_Signal, metadata: grpc.Metadata, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 29 | ClientIcecandidate(argument: _mooyaho_Signal, metadata: grpc.Metadata, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 30 | ClientIcecandidate(argument: _mooyaho_Signal, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 31 | ClientIcecandidate(argument: _mooyaho_Signal, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 32 | clientIcecandidate(argument: _mooyaho_Signal, metadata: grpc.Metadata, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 33 | clientIcecandidate(argument: _mooyaho_Signal, metadata: grpc.Metadata, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 34 | clientIcecandidate(argument: _mooyaho_Signal, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 35 | clientIcecandidate(argument: _mooyaho_Signal, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 36 | 37 | Leave(argument: _mooyaho_LeaveParams, metadata: grpc.Metadata, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 38 | Leave(argument: _mooyaho_LeaveParams, metadata: grpc.Metadata, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 39 | Leave(argument: _mooyaho_LeaveParams, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 40 | Leave(argument: _mooyaho_LeaveParams, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 41 | leave(argument: _mooyaho_LeaveParams, metadata: grpc.Metadata, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 42 | leave(argument: _mooyaho_LeaveParams, metadata: grpc.Metadata, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 43 | leave(argument: _mooyaho_LeaveParams, options: grpc.CallOptions, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 44 | leave(argument: _mooyaho_LeaveParams, callback: (error?: grpc.ServiceError, result?: _mooyaho_Empty__Output) => void): grpc.ClientUnaryCall; 45 | 46 | ListenSignal(argument: _mooyaho_Empty, metadata: grpc.Metadata, options?: grpc.CallOptions): grpc.ClientReadableStream<_mooyaho_Signal__Output>; 47 | ListenSignal(argument: _mooyaho_Empty, options?: grpc.CallOptions): grpc.ClientReadableStream<_mooyaho_Signal__Output>; 48 | listenSignal(argument: _mooyaho_Empty, metadata: grpc.Metadata, options?: grpc.CallOptions): grpc.ClientReadableStream<_mooyaho_Signal__Output>; 49 | listenSignal(argument: _mooyaho_Empty, options?: grpc.CallOptions): grpc.ClientReadableStream<_mooyaho_Signal__Output>; 50 | 51 | } 52 | 53 | export interface MooyahoHandlers extends grpc.UntypedServiceImplementation { 54 | Answer: grpc.handleUnaryCall<_mooyaho_Signal__Output, _mooyaho_Empty>; 55 | 56 | Call: grpc.handleUnaryCall<_mooyaho_Signal__Output, _mooyaho_Signal>; 57 | 58 | ClientIcecandidate: grpc.handleUnaryCall<_mooyaho_Signal__Output, _mooyaho_Empty>; 59 | 60 | Leave: grpc.handleUnaryCall<_mooyaho_LeaveParams__Output, _mooyaho_Empty>; 61 | 62 | ListenSignal: grpc.handleServerStreamingCall<_mooyaho_Empty__Output, _mooyaho_Signal>; 63 | 64 | } 65 | 66 | export interface MooyahoDefinition extends grpc.ServiceDefinition { 67 | Answer: MethodDefinition<_mooyaho_Signal, _mooyaho_Empty, _mooyaho_Signal__Output, _mooyaho_Empty__Output> 68 | Call: MethodDefinition<_mooyaho_Signal, _mooyaho_Signal, _mooyaho_Signal__Output, _mooyaho_Signal__Output> 69 | ClientIcecandidate: MethodDefinition<_mooyaho_Signal, _mooyaho_Empty, _mooyaho_Signal__Output, _mooyaho_Empty__Output> 70 | Leave: MethodDefinition<_mooyaho_LeaveParams, _mooyaho_Empty, _mooyaho_LeaveParams__Output, _mooyaho_Empty__Output> 71 | ListenSignal: MethodDefinition<_mooyaho_Empty, _mooyaho_Signal, _mooyaho_Empty__Output, _mooyaho_Signal__Output> 72 | } 73 | -------------------------------------------------------------------------------- /packages/mooyaho-grpc/src/protos/mooyaho/Signal.ts: -------------------------------------------------------------------------------- 1 | // Original file: protos/mooyaho.proto 2 | 3 | 4 | export interface Signal { 5 | 'type'?: (string); 6 | 'sessionId'?: (string); 7 | 'sdp'?: (string); 8 | 'candidate'?: (string); 9 | 'channelId'?: (string); 10 | 'fromSessionId'?: (string); 11 | } 12 | 13 | export interface Signal__Output { 14 | 'type': (string); 15 | 'sessionId': (string); 16 | 'sdp': (string); 17 | 'candidate': (string); 18 | 'channelId': (string); 19 | 'fromSessionId': (string); 20 | } 21 | -------------------------------------------------------------------------------- /packages/mooyaho-grpc/tsconfig.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "compilerOptions": { 4 | "strict": true, 5 | "lib": [ 6 | "es5", 7 | "es6", 8 | "esnext", 9 | "DOM" 10 | ], 11 | "target": "es2020", 12 | "module": "commonjs", 13 | "moduleResolution": "node", 14 | "outDir": "./dist", 15 | "emitDecoratorMetadata": true, 16 | "experimentalDecorators": true, 17 | "sourceMap": true, 18 | "esModuleInterop": true, 19 | "resolveJsonModule": true, 20 | "allowJs": true, 21 | "declaration": true 22 | } 23 | } -------------------------------------------------------------------------------- /packages/mooyaho-server-sdk/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /packages/mooyaho-server-sdk/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "arrowParens": "avoid", 5 | "printWidth": 100, 6 | "tabWidth": 2 7 | } -------------------------------------------------------------------------------- /packages/mooyaho-server-sdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mooyaho/server-sdk", 3 | "version": "1.0.0-alpha.2", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "license": "MIT", 7 | "devDependencies": { 8 | "@types/axios": "^0.14.0", 9 | "typescript": "^4.3.5" 10 | }, 11 | "dependencies": { 12 | "axios": "^0.21.1" 13 | }, 14 | "scripts": { 15 | "build": "tsc" 16 | }, 17 | "files": [ 18 | "/dist" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /packages/mooyaho-server-sdk/src/Mooyaho.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { Channel, ChannelSession, ChannelWithSessions } from './types' 3 | 4 | const apiClient = axios.create() 5 | 6 | class Mooyaho { 7 | constructor(baseUrl: string, apiKey: string) { 8 | apiClient.defaults.baseURL = baseUrl 9 | apiClient.defaults.headers['Authorization'] = `Bearer ${apiKey}` 10 | } 11 | 12 | async createChannel(isSFU?: boolean) { 13 | const response = await apiClient.post('/channels', { 14 | sfuEnabled: isSFU, 15 | }) 16 | return response.data 17 | } 18 | 19 | async deleteChannel(channelId: string) { 20 | await apiClient.delete(`/channels/${channelId}`) 21 | return true 22 | } 23 | 24 | async getChannel(channelId: string) { 25 | const response = await apiClient.get(`/channels/${channelId}`) 26 | return response.data 27 | } 28 | 29 | async integrateUser(sessionId: string, user: { id: string; [key: string]: any }) { 30 | const response = await apiClient.post(`/sessions/${sessionId}`, user) 31 | return response.data 32 | } 33 | 34 | async listAllChannels() { 35 | const response = await apiClient.get<(Channel & { sessionCount: number })[]>('/channels') 36 | return response.data 37 | } 38 | 39 | async bulkDeleteChannels(channelIds: string[]) { 40 | await apiClient.post(`/channels/bulk-delete`, channelIds) 41 | return true 42 | } 43 | } 44 | 45 | export default Mooyaho 46 | -------------------------------------------------------------------------------- /packages/mooyaho-server-sdk/src/index.ts: -------------------------------------------------------------------------------- 1 | import Mooyaho from './Mooyaho' 2 | 3 | export default Mooyaho 4 | export { Mooyaho } 5 | -------------------------------------------------------------------------------- /packages/mooyaho-server-sdk/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Channel = { 2 | id: string 3 | sfuServerId: null | number 4 | } 5 | 6 | export type ChannelSession = { 7 | sessionId: string 8 | user: { 9 | id: string 10 | [key: string]: any 11 | } 12 | } 13 | 14 | export type ChannelWithSessions = Channel & { 15 | channelSessions: ChannelSession[] 16 | } 17 | -------------------------------------------------------------------------------- /packages/mooyaho-server-sdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "compilerOptions": { 4 | "strict": true, 5 | "lib": [ 6 | "es5", 7 | "es6", 8 | "esnext" 9 | ], 10 | "target": "es2020", 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "outDir": "./dist", 14 | "emitDecoratorMetadata": true, 15 | "experimentalDecorators": true, 16 | "sourceMap": true, 17 | "esModuleInterop": true, 18 | "resolveJsonModule": true, 19 | "allowJs": true, 20 | "declaration": true 21 | } 22 | } -------------------------------------------------------------------------------- /packages/mooyaho-sfu/.env: -------------------------------------------------------------------------------- 1 | PORT=50000 -------------------------------------------------------------------------------- /packages/mooyaho-sfu/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /packages/mooyaho-sfu/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 80, 6 | "arrowParens": "avoid" 7 | } -------------------------------------------------------------------------------- /packages/mooyaho-sfu/README.md: -------------------------------------------------------------------------------- 1 | Use v14 Node 2 | -------------------------------------------------------------------------------- /packages/mooyaho-sfu/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mooyaho-sfu", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "private": null, 7 | "dependencies": { 8 | "@grpc/grpc-js": "^1.3.2", 9 | "@mooyaho/grpc": "^1.0.0", 10 | "google-protobuf": "^3.17.3", 11 | "ts-node": "^10.0.0", 12 | "ts-node-dev": "^1.1.6", 13 | "typescript": "^4.3.2", 14 | "wrtc": "^0.4.7" 15 | }, 16 | "scripts": { 17 | "start": "ts-node -T ./src/main.ts", 18 | "dev": "ts-node-dev -T ./src/main.ts", 19 | "start:client": "ts-node -T ./src/client.ts" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/mooyaho-sfu/src/channel/Channel.ts: -------------------------------------------------------------------------------- 1 | import Connection from './Connection' 2 | import ConnectionManager from './ConnectionManager' 3 | 4 | export default class Channel { 5 | connections = new ConnectionManager() 6 | 7 | constructor(private id: string) {} 8 | 9 | getConnectionById(id: string) { 10 | return this.connections.getConnectionById(id) 11 | } 12 | 13 | addConnection(connection: Connection) { 14 | connection.channel = this 15 | this.connections.add(connection.id, connection) 16 | } 17 | 18 | getConnectionsExcept(id: string) { 19 | return Array.from(this.connections.getAll()).filter( 20 | connection => connection.id !== id 21 | ) 22 | } 23 | 24 | removeConnection(connection: Connection) { 25 | this.connections.remove(connection.id) 26 | Array.from(this.connections.getAll()).forEach(c => { 27 | c.removeFromOutputConnections(connection.id) 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/mooyaho-sfu/src/channel/ChannelManager.ts: -------------------------------------------------------------------------------- 1 | import Channel from './Channel' 2 | 3 | export default class ChannelManager { 4 | channels = new Map() 5 | 6 | getChannelById(id: string) { 7 | let channel = this.channels.get(id) 8 | if (!channel) { 9 | channel = new Channel(id) 10 | this.channels.set(id, channel) 11 | } 12 | return channel 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/mooyaho-sfu/src/channel/Connection.ts: -------------------------------------------------------------------------------- 1 | import Channel from './Channel' 2 | import { RTCPeerConnection, MediaStream, RTCIceCandidate } from 'wrtc' 3 | import getDispatchSignal from '../getDispatchSignal' 4 | 5 | const config = {} 6 | 7 | export default class Connection { 8 | channel: Channel | null = null 9 | peerConnection: RTCPeerConnection | null = null 10 | stream: MediaStream | null = null 11 | outputPeerConnections = new Map() 12 | outputPeerCandidateQueue = new Map() 13 | isConnected = false 14 | 15 | constructor(public id: string) {} 16 | 17 | async call(connection: Connection) { 18 | const { stream } = connection 19 | if (!stream) return 20 | 21 | const peer = new RTCPeerConnection(config) 22 | 23 | this.outputPeerConnections.set(connection.id, peer) 24 | peer.addEventListener('icecandidate', e => { 25 | if (!e.candidate) return 26 | const dispatch = getDispatchSignal() 27 | 28 | dispatch({ 29 | type: 'icecandidate', 30 | sessionId: this.id, 31 | candidate: JSON.stringify(e.candidate), 32 | fromSessionId: connection.id, 33 | }) 34 | }) 35 | 36 | stream.getTracks().forEach(track => { 37 | peer.addTrack(track, stream) 38 | }) 39 | 40 | const offer = await peer.createOffer() 41 | const dispatch = getDispatchSignal() 42 | peer.setLocalDescription(offer) 43 | 44 | dispatch({ 45 | type: 'offer', 46 | sessionId: this.id, 47 | fromSessionId: connection.id, 48 | sdp: offer.sdp, 49 | }) 50 | } 51 | 52 | async receiveCall(sdp: string) { 53 | const peer = new RTCPeerConnection(config) 54 | this.peerConnection = peer 55 | 56 | peer.addEventListener('track', e => { 57 | const stream = e.streams[0] 58 | this.stream = stream 59 | }) 60 | 61 | await peer.setRemoteDescription({ 62 | type: 'offer', 63 | sdp, 64 | }) 65 | 66 | peer.addEventListener('icecandidate', e => { 67 | if (!e.candidate) return 68 | // ensures answer first! 69 | setTimeout(() => { 70 | const dispatch = getDispatchSignal() 71 | dispatch({ 72 | type: 'icecandidate', 73 | sessionId: this.id, 74 | candidate: JSON.stringify(e.candidate), 75 | }) 76 | }, 50) 77 | }) 78 | 79 | peer.addEventListener('connectionstatechange', e => { 80 | console.log(peer.connectionState) 81 | if (peer.connectionState === 'connected' && !this.isConnected) { 82 | this.isConnected = true 83 | const connections = this.channel!.getConnectionsExcept(this.id) 84 | // (1) send other's stream to this peer 85 | connections.forEach(connection => this.call(connection)) 86 | // (2) send this peer's stream to other users 87 | connections.forEach(connection => connection.call(this)) 88 | } else if (peer.connectionState === 'failed' && this.isConnected) { 89 | this.dispose() 90 | // remove this connection from SFU 91 | } 92 | }) 93 | 94 | const answer = await peer.createAnswer() 95 | peer.setLocalDescription(answer) 96 | return answer 97 | } 98 | 99 | dispose() { 100 | this.isConnected = false 101 | this.peerConnection?.close() 102 | this.outputPeerConnections.forEach(peer => peer.close()) 103 | this.channel?.removeConnection(this) 104 | console.log('I am disposed...') 105 | } 106 | 107 | async receiveAnswer(sessionId: string, sdp: string) { 108 | const outputPeer = this.outputPeerConnections.get(sessionId) 109 | if (!outputPeer) return 110 | 111 | await outputPeer.setRemoteDescription({ 112 | type: 'answer', 113 | sdp, 114 | }) 115 | 116 | const queue = this.outputPeerCandidateQueue.get(sessionId) ?? [] 117 | if (queue.length > 0) { 118 | queue.forEach(candidate => { 119 | outputPeer.addIceCandidate(candidate) 120 | }) 121 | } 122 | } 123 | 124 | addIceCandidate(candidate: any) { 125 | if (!this.peerConnection || !candidate) return 126 | this.peerConnection.addIceCandidate(candidate) 127 | } 128 | 129 | addIceCandidateForOutputPeer(sessionId: string, candidate: any) { 130 | if (!candidate) return 131 | const outputPeer = this.outputPeerConnections.get(sessionId) 132 | if (!outputPeer) return 133 | const queue = this.outputPeerCandidateQueue.get(sessionId) ?? [] 134 | if (!outputPeer.remoteDescription) { 135 | queue.push(candidate) 136 | this.outputPeerCandidateQueue.set(sessionId, queue) 137 | } else { 138 | outputPeer.addIceCandidate(candidate) 139 | } 140 | // if (!outputPeer) return 141 | } 142 | 143 | async getStream() { 144 | while (!this.stream) { 145 | await sleep(50) 146 | } 147 | return this.stream 148 | } 149 | 150 | removeFromOutputConnections(id: string) { 151 | const peer = this.outputPeerConnections.get(id) 152 | peer?.close() 153 | this.outputPeerConnections.delete(id) 154 | } 155 | } 156 | 157 | const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t)) 158 | -------------------------------------------------------------------------------- /packages/mooyaho-sfu/src/channel/ConnectionManager.ts: -------------------------------------------------------------------------------- 1 | import Connection from './Connection' 2 | 3 | export default class ConnectionManager { 4 | connections = new Map() 5 | 6 | constructor() {} 7 | 8 | getConnectionById(id: string) { 9 | let connection = this.connections.get(id) 10 | if (!connection) { 11 | connection = new Connection(id) 12 | this.connections.set(id, connection) 13 | } 14 | return connection 15 | } 16 | 17 | add(id: string, connection: Connection) { 18 | this.connections.set(id, connection) 19 | } 20 | 21 | getAll() { 22 | return this.connections.values() 23 | } 24 | 25 | remove(id: string) { 26 | this.connections.delete(id) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/mooyaho-sfu/src/getDispatchSignal.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from '@mooyaho/grpc' 2 | 3 | let dispatchSignal: Callback | null = null 4 | export function registerDispatchSignal(callback: Callback) { 5 | dispatchSignal = callback 6 | } 7 | 8 | type Callback = (signal: Signal) => void 9 | 10 | export default function getDispatchSignal() { 11 | if (!dispatchSignal) throw new Error('dispatchSignal is not registered') 12 | return dispatchSignal 13 | } 14 | -------------------------------------------------------------------------------- /packages/mooyaho-sfu/src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'wrtc' { 2 | export var RTCPeerConnection: { 3 | prototype: RTCPeerConnection 4 | new (configuration?: RTCConfiguration): RTCPeerConnection 5 | generateCertificate( 6 | keygenAlgorithm: AlgorithmIdentifier 7 | ): Promise 8 | getDefaultIceServers(): RTCIceServer[] 9 | } 10 | export var MediaStream: { 11 | prototype: MediaStream 12 | new (): MediaStream 13 | new (stream: MediaStream): MediaStream 14 | new (tracks: MediaStreamTrack[]): MediaStream 15 | } 16 | 17 | interface RTCIceCandidate { 18 | readonly candidate: string 19 | readonly component: RTCIceComponent | null 20 | readonly foundation: string | null 21 | readonly port: number | null 22 | readonly priority: number | null 23 | readonly protocol: RTCIceProtocol | null 24 | readonly relatedAddress: string | null 25 | readonly relatedPort: number | null 26 | readonly sdpMLineIndex: number | null 27 | readonly sdpMid: string | null 28 | readonly tcpType: RTCIceTcpCandidateType | null 29 | readonly type: RTCIceCandidateType | null 30 | readonly usernameFragment: string | null 31 | toJSON(): RTCIceCandidateInit 32 | } 33 | 34 | export var RTCIceCandidate: { 35 | prototype: RTCIceCandidate 36 | new (candidateInitDict?: RTCIceCandidateInit): RTCIceCandidate 37 | } 38 | 39 | interface RTCSessionDescription { 40 | readonly sdp: string 41 | readonly type: RTCSdpType 42 | toJSON(): any 43 | } 44 | 45 | export var RTCSessionDescription: { 46 | prototype: RTCSessionDescription 47 | new (descriptionInitDict: RTCSessionDescriptionInit): RTCSessionDescription 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/mooyaho-sfu/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ServerCredentials, Server } from '@grpc/grpc-js' 2 | import proto, { MooyahoHandlers } from '@mooyaho/grpc' 3 | import ChannelManager from './channel/ChannelManager' 4 | import ConnectionManager from './channel/ConnectionManager' 5 | import { registerDispatchSignal } from './getDispatchSignal' 6 | 7 | const server = new Server() 8 | 9 | const channels = new ChannelManager() 10 | const connections = new ConnectionManager() 11 | 12 | const mooyahoServer: MooyahoHandlers = { 13 | async Call(call, callback) { 14 | /* 15 | 1. create or get channel 16 | 2. create new connection via connection manager 17 | 3. push connection to the channel 18 | */ 19 | const { channelId, sdp, sessionId } = call.request 20 | const channel = channels.getChannelById(channelId) 21 | const connection = connections.getConnectionById(sessionId) 22 | channel.addConnection(connection) 23 | const answer = await connection.receiveCall(sdp) 24 | 25 | callback(null, { 26 | sdp: answer.sdp, 27 | }) 28 | }, 29 | ClientIcecandidate(call, callback) { 30 | const { sessionId, candidate, fromSessionId } = call.request 31 | const connection = connections.getConnectionById(fromSessionId) 32 | try { 33 | const parsedCandidate = JSON.parse(candidate) 34 | if (sessionId) { 35 | connection?.addIceCandidateForOutputPeer(sessionId, parsedCandidate) 36 | } else { 37 | connection?.addIceCandidate(parsedCandidate) 38 | } 39 | } catch (e) {} 40 | 41 | callback(null, {}) 42 | }, 43 | ListenSignal(call) { 44 | registerDispatchSignal(signal => { 45 | call.write(signal) 46 | }) 47 | // const connection = connections.getConnectionById(call.request.sessionId) 48 | // if (!connection) { 49 | // call.end() 50 | // return 51 | // } 52 | // connection.registerCandidateHandler(candidate => { 53 | // const str = JSON.stringify(candidate) 54 | // call.write({ candidate: str }) 55 | // }) 56 | // connection.exitCandidate = () => { 57 | // call.end() 58 | // } 59 | }, 60 | Answer(call, callback) { 61 | callback(null, {}) 62 | const { channelId, sessionId, fromSessionId, sdp } = call.request 63 | const channel = channels.getChannelById(channelId) 64 | if (!channel) return 65 | const connection = channel.getConnectionById(fromSessionId) 66 | if (!connection) return 67 | connection.receiveAnswer(sessionId, sdp) 68 | }, 69 | Leave(call, callback) { 70 | callback(null, {}) 71 | const { channelId, sessionId } = call.request 72 | const channel = channels.getChannelById(channelId) 73 | const connection = channel?.getConnectionById(sessionId) 74 | connection?.dispose() 75 | }, 76 | } 77 | 78 | const port = process.env.PORT ?? '50000' 79 | 80 | server.addService(proto.mooyaho.Mooyaho.service, mooyahoServer) 81 | server.bindAsync( 82 | `localhost:${port}`, 83 | ServerCredentials.createInsecure(), 84 | (err, port) => { 85 | server.start() 86 | console.log(`Running server on ${port}...`) 87 | } 88 | ) 89 | -------------------------------------------------------------------------------- /packages/mooyaho-sfu/tsconfig.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "compilerOptions": { 4 | "strict": true, 5 | "lib": [ 6 | "es5", 7 | "es6", 8 | "esnext", 9 | "DOM" 10 | ], 11 | "target": "es2020", 12 | "module": "commonjs", 13 | "moduleResolution": "node", 14 | "outDir": "./build", 15 | "emitDecoratorMetadata": true, 16 | "experimentalDecorators": true, 17 | "sourceMap": true, 18 | "esModuleInterop": true, 19 | "resolveJsonModule": true, 20 | "allowJs": true, 21 | } 22 | } -------------------------------------------------------------------------------- /packages/webrtc-sample/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /packages/webrtc-sample/README.md: -------------------------------------------------------------------------------- 1 | WebRTC peer to peer connection sample. 2 | 3 | This project will be removed later. 4 | -------------------------------------------------------------------------------- /packages/webrtc-sample/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Starter Snowpack App 8 | 9 | 10 |
11 | 12 | 13 | 14 |
15 | 21 | 22 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /packages/webrtc-sample/index.js: -------------------------------------------------------------------------------- 1 | import "./style.css"; 2 | 3 | const ws = new WebSocket("ws://localhost:8081/websocket"); 4 | 5 | const rtcConfiguration = {}; 6 | 7 | ws.addEventListener("message", (event) => { 8 | handleMessage(event.data.toString()); 9 | }); 10 | 11 | ws.addEventListener("open", () => { 12 | integrateUser("tester"); 13 | }); 14 | 15 | function sendJSON(object) { 16 | const message = JSON.stringify(object); 17 | console.log(message); 18 | ws.send(message); 19 | } 20 | 21 | let sessionId = null; 22 | const localPeers = {}; 23 | 24 | function handleMessage(message) { 25 | try { 26 | const action = JSON.parse(message); 27 | if (!action.type) { 28 | throw new Error("There is no type in action"); 29 | } 30 | 31 | switch (action.type) { 32 | case "connected": 33 | console.log(`sessionId: ${action.id}`); 34 | sessionId = action.id; 35 | break; 36 | case "entered": 37 | if (action.sessionId === sessionId) { 38 | listSessions(); 39 | break; 40 | } 41 | call(action.sessionId); 42 | break; 43 | case "called": 44 | answer(action.from, action.sdp); 45 | break; 46 | case "answered": 47 | answered(action.from, action.sdp); 48 | break; 49 | case "candidated": 50 | candidated(action.from, action.candidate); 51 | break; 52 | } 53 | } catch (e) { 54 | console.log(e); 55 | } 56 | } 57 | 58 | const channelForm = document.querySelector("#channelForm"); 59 | 60 | async function createMediaStream() { 61 | try { 62 | const stream = await navigator.mediaDevices.getUserMedia({ 63 | audio: true, 64 | video: true, 65 | }); 66 | return stream; 67 | } catch (e) { 68 | console.error(e); 69 | } 70 | } 71 | 72 | function enterChannel(channelName) { 73 | sendJSON({ 74 | type: "enter", 75 | channel: channelName, 76 | }); 77 | } 78 | 79 | function listSessions() { 80 | sendJSON({ 81 | type: "listSessions", 82 | }); 83 | } 84 | 85 | async function call(to) { 86 | const stream = localStream; 87 | 88 | const localPeer = new RTCPeerConnection(rtcConfiguration); 89 | localPeer.addEventListener("connectionstatechange", (e) => { 90 | console.log({ connectionState: e.target.connectionState }); 91 | }); 92 | localPeers[to] = localPeer; 93 | 94 | localPeer.addEventListener("icecandidate", (e) => { 95 | console.log("hello"); 96 | icecandidate(to, e.candidate); 97 | }); 98 | 99 | const video = document.createElement("video"); 100 | document.body.appendChild(video); 101 | video.autoplay = true; 102 | 103 | localPeer.addEventListener("track", (ev) => { 104 | console.log({ streams: ev.streams }); 105 | if (video.srcObject !== ev.streams[0]) { 106 | video.srcObject = ev.streams[0]; 107 | } 108 | }); 109 | 110 | stream.getTracks().forEach((track) => { 111 | console.log(track); 112 | localPeer.addTrack(track, stream); 113 | }); 114 | 115 | const offer = await localPeer.createOffer(); 116 | console.log("offer: ", offer); 117 | await localPeer.setLocalDescription(offer); 118 | 119 | sendJSON({ 120 | type: "call", 121 | to, 122 | sdp: offer.sdp, 123 | }); 124 | } 125 | 126 | async function answer(to, sdp) { 127 | const stream = localStream; 128 | const localPeer = new RTCPeerConnection(rtcConfiguration); 129 | localPeers[to] = localPeer; 130 | 131 | localPeer.addEventListener("icecandidate", (e) => { 132 | console.log("bye"); 133 | icecandidate(to, e.candidate); 134 | }); 135 | 136 | const video = document.createElement("video"); 137 | document.body.appendChild(video); 138 | video.autoplay = true; 139 | 140 | localPeer.addEventListener("track", (ev) => { 141 | if (video.srcObject !== ev.streams[0]) { 142 | video.srcObject = ev.streams[0]; 143 | } 144 | }); 145 | 146 | stream.getTracks().forEach((track) => { 147 | localPeer.addTrack(track, stream); 148 | }); 149 | 150 | await localPeer.setRemoteDescription({ 151 | type: "offer", 152 | sdp, 153 | }); 154 | const answer = await localPeer.createAnswer(); 155 | await localPeer.setLocalDescription(answer); 156 | 157 | sendJSON({ 158 | type: "answer", 159 | to, 160 | sdp: answer.sdp, 161 | }); 162 | } 163 | 164 | async function answered(from, sdp) { 165 | const localPeer = localPeers[from]; 166 | if (!localPeer) { 167 | console.error(`localPeer ${from} does not exist`); 168 | return; 169 | } 170 | await localPeer.setRemoteDescription({ 171 | type: "answer", 172 | sdp, 173 | }); 174 | console.log(`setRemoteDescription success for ${from}`); 175 | } 176 | 177 | function icecandidate(to, candidate) { 178 | sendJSON({ 179 | type: "candidate", 180 | to, 181 | candidate, 182 | }); 183 | } 184 | 185 | function candidated(from, candidate) { 186 | const localPeer = localPeers[from]; 187 | if (!localPeer) { 188 | console.error(`localPeer ${from} does not exist`); 189 | return; 190 | } 191 | 192 | try { 193 | localPeer.addIceCandidate(candidate); 194 | console.log(`Candidate from ${from} success!`); 195 | } catch (e) { 196 | console.error(`Failed to candidate: ${e.toString()}`); 197 | } 198 | } 199 | 200 | channelForm.addEventListener("submit", async (e) => { 201 | channelForm.querySelector("button").disabled = true; 202 | e.preventDefault(); 203 | 204 | enterChannel(channelForm.channelName.value); 205 | }); 206 | 207 | let localStream = null; 208 | createMediaStream().then((stream) => { 209 | localStream = stream; 210 | const myVideo = document.createElement("video"); 211 | document.body.appendChild(myVideo); 212 | myVideo.autoplay = true; 213 | 214 | myVideo.srcObject = stream; 215 | myVideo.volume = 0; 216 | }); 217 | 218 | function integrateUser(displayName) { 219 | sendJSON({ 220 | type: "integrateUser", 221 | user: { 222 | displayName, 223 | }, 224 | }); 225 | } 226 | 227 | window.integrateUser = integrateUser; 228 | 229 | // const button = document.body.querySelector('#btnLoadCam'); 230 | 231 | // async function loadCamera() { 232 | // try { 233 | // const stream = await navigator.mediaDevices.getUserMedia({ 234 | // audio: false, 235 | // video: true 236 | // }); 237 | // const videoTracks = stream.getVideoTracks() 238 | // myVideo.srcObject = stream 239 | // } catch (e) { 240 | // console.error(e); 241 | // } 242 | // } 243 | 244 | // button.addEventListener('click', () => { 245 | // loadCamera() 246 | // }) 247 | -------------------------------------------------------------------------------- /packages/webrtc-sample/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webrtc-sample", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "devDependencies": { 7 | "snowpack": "^3.5.1" 8 | }, 9 | "scripts": { 10 | "start": "snowpack dev" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/webrtc-sample/sfu.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Starter Snowpack App 8 | 9 | 10 |
11 | 12 | 13 | 14 |
15 | 21 | 22 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /packages/webrtc-sample/sfu.js: -------------------------------------------------------------------------------- 1 | import "./style.css"; 2 | 3 | const ws = new WebSocket("ws://localhost:8081/websocket"); 4 | 5 | const rtcConfiguration = {}; 6 | 7 | ws.addEventListener("open", () => { 8 | integrateUser("tester"); 9 | }); 10 | 11 | ws.addEventListener("message", (event) => { 12 | handleMessage(event.data.toString()); 13 | }); 14 | 15 | function sendJSON(object) { 16 | const message = JSON.stringify(object); 17 | console.group("Send"); 18 | console.log(object.type); 19 | console.log(object); 20 | console.groupEnd("Send"); 21 | ws.send(message); 22 | } 23 | 24 | let sessionId = null; 25 | const localPeers = {}; 26 | 27 | const config = { 28 | sfuEnabled: false, 29 | }; 30 | 31 | function handleMessage(message) { 32 | try { 33 | const action = JSON.parse(message); 34 | console.group("Receive"); 35 | console.log(action.type); 36 | console.log(action); 37 | console.groupEnd("Receive"); 38 | if (!action.type) { 39 | throw new Error("There is no type in action"); 40 | } 41 | 42 | switch (action.type) { 43 | case "connected": 44 | console.log(`sessionId: ${action.id}`); 45 | sessionId = action.id; 46 | break; 47 | case "entered": 48 | if (action.sessionId === sessionId) { 49 | break; 50 | } 51 | if (config.sfuEnabled) return; 52 | call(action.sessionId); 53 | break; 54 | case "called": 55 | answer(action.from, action.sdp, action.isSFU); 56 | break; 57 | case "answered": 58 | answered(action.from, action.sdp, action.isSFU); 59 | break; 60 | case "candidated": 61 | candidated(action.from, action.candidate, action.isSFU); 62 | break; 63 | case "enterSuccess": 64 | enterSuccess(action.sfuEnabled); 65 | break; 66 | } 67 | } catch (e) { 68 | console.log(e); 69 | } 70 | } 71 | 72 | const channelForm = document.querySelector("#channelForm"); 73 | 74 | async function createMediaStream() { 75 | try { 76 | const stream = await navigator.mediaDevices.getUserMedia({ 77 | audio: true, 78 | video: { width: 426, height: 240 }, 79 | }); 80 | return stream; 81 | } catch (e) { 82 | console.error(e); 83 | } 84 | } 85 | 86 | function enterChannel(channelName) { 87 | sendJSON({ 88 | type: "enter", 89 | channel: channelName, 90 | }); 91 | } 92 | 93 | function listSessions() { 94 | sendJSON({ 95 | type: "listSessions", 96 | }); 97 | } 98 | 99 | async function call(to) { 100 | const stream = await createMediaStream(); 101 | 102 | const localPeer = new RTCPeerConnection(rtcConfiguration); 103 | 104 | localPeers[to] = localPeer; 105 | localPeer.addEventListener("connectionstatechange", (e) => {}); 106 | localPeer.addEventListener("icecandidate", (e) => { 107 | icecandidate(to, e.candidate); 108 | }); 109 | 110 | const video = document.createElement("video"); 111 | document.body.appendChild(video); 112 | video.autoplay = true; 113 | 114 | localPeer.addEventListener("track", (ev) => { 115 | if (video.srcObject !== ev.streams[0]) { 116 | video.srcObject = ev.streams[0]; 117 | } 118 | }); 119 | 120 | stream.getTracks().forEach((track) => { 121 | localPeer.addTrack(track, stream); 122 | }); 123 | 124 | const offer = await localPeer.createOffer(); 125 | await localPeer.setLocalDescription(offer); 126 | 127 | sendJSON({ 128 | type: "call", 129 | to, 130 | description: offer, 131 | }); 132 | } 133 | 134 | async function answer(to, sdp, isSFU) { 135 | if (isSFU) { 136 | const localPeer = new RTCPeerConnection(rtcConfiguration); 137 | 138 | localPeers[to] = localPeer; 139 | 140 | localPeer.addEventListener("icecandidate", (e) => { 141 | sfuCandidate(e.candidate, to); 142 | }); 143 | localPeer.addEventListener("connectionstatechange", (e) => { 144 | if (sfuPeer.connectionState === "connected") { 145 | } 146 | }); 147 | 148 | const video = document.createElement("video"); 149 | document.body.appendChild(video); 150 | video.autoplay = true; 151 | 152 | localPeer.addEventListener("track", (ev) => { 153 | console.log("track", ev.streams); 154 | if (video.srcObject !== ev.streams[0]) { 155 | video.srcObject = ev.streams[0]; 156 | } 157 | }); 158 | 159 | await localPeer.setRemoteDescription({ type: "offer", sdp }); 160 | 161 | const answer = await localPeer.createAnswer(); 162 | await localPeer.setLocalDescription(answer); 163 | 164 | sendJSON({ 165 | type: "answer", 166 | to, 167 | sdp: answer.sdp, 168 | isSFU: true, 169 | }); 170 | return; 171 | } 172 | 173 | const stream = localStream; 174 | const localPeer = new RTCPeerConnection(rtcConfiguration); 175 | localPeers[to] = localPeer; 176 | 177 | localPeer.addEventListener("icecandidate", (e) => { 178 | icecandidate(to, e.candidate); 179 | }); 180 | 181 | const video = document.createElement("video"); 182 | document.body.appendChild(video); 183 | video.autoplay = true; 184 | 185 | localPeer.addEventListener("track", (ev) => { 186 | if (video.srcObject !== ev.streams[0]) { 187 | video.srcObject = ev.streams[0]; 188 | } 189 | }); 190 | 191 | stream.getTracks().forEach((track) => { 192 | localPeer.addTrack(track, stream); 193 | }); 194 | 195 | await localPeer.setRemoteDescription({ 196 | type: "offer", 197 | sdp, 198 | }); 199 | const answer = await localPeer.createAnswer(); 200 | await localPeer.setLocalDescription(answer); 201 | 202 | sendJSON({ 203 | type: "answer", 204 | to, 205 | sdp: answer.sdp, 206 | }); 207 | } 208 | 209 | async function answered(from, sdp, isSFU) { 210 | if (isSFU) { 211 | if (!sfuPeerConnection) { 212 | console.error("sfuPeer does not exist"); 213 | return; 214 | } 215 | 216 | console.log({ 217 | type: "answer", 218 | sdp, 219 | }); 220 | await sfuPeerConnection.setRemoteDescription( 221 | new RTCSessionDescription({ 222 | type: "answer", 223 | sdp, 224 | }) 225 | ); 226 | return; 227 | } 228 | 229 | const localPeer = localPeers[from]; 230 | if (!localPeer) { 231 | console.error(`localPeer ${from} does not exist`); 232 | return; 233 | } 234 | await localPeer.setRemoteDescription({ 235 | type: "answer", 236 | sdp, 237 | }); 238 | console.log(`setRemoteDescription success for ${from}`); 239 | } 240 | function icecandidate(to, candidate) { 241 | sendJSON({ 242 | type: "candidate", 243 | to, 244 | candidate, 245 | }); 246 | } 247 | 248 | function candidated(from, candidate, isSFU) { 249 | if (isSFU) { 250 | if (!from) { 251 | sfuPeerConnection.addIceCandidate(candidate); 252 | } else { 253 | const localPeer = localPeers[from]; 254 | if (!localPeer) { 255 | console.error("localPeer not found"); 256 | return; 257 | } 258 | localPeer.addIceCandidate(candidate); 259 | } 260 | return; 261 | } 262 | const localPeer = localPeers[from]; 263 | if (!localPeer) { 264 | console.error(`localPeer ${from} does not exist`); 265 | return; 266 | } 267 | 268 | try { 269 | localPeer.addIceCandidate(candidate); 270 | console.log(`Candidate from ${from} success!`); 271 | } catch (e) { 272 | console.error(`Failed to candidate: ${e.toString()}`); 273 | } 274 | } 275 | channelForm.addEventListener("submit", async (e) => { 276 | channelForm.querySelector("button").disabled = true; 277 | e.preventDefault(); 278 | 279 | enterChannel(channelForm.channelName.value); 280 | }); 281 | 282 | let localStream = null; 283 | createMediaStream().then((stream) => { 284 | localStream = stream; 285 | const myVideo = document.createElement("video"); 286 | document.body.appendChild(myVideo); 287 | myVideo.autoplay = true; 288 | 289 | myVideo.srcObject = stream; 290 | myVideo.volume = 0; 291 | }); 292 | 293 | function integrateUser(displayName) { 294 | sendJSON({ 295 | type: "integrateUser", 296 | user: { 297 | displayName, 298 | }, 299 | }); 300 | } 301 | 302 | let sfuPeerConnection = null; 303 | 304 | function enterSuccess(sfuEnabled) { 305 | config.sfuEnabled = sfuEnabled; 306 | listSessions(); 307 | if (!sfuEnabled) return; 308 | sfuCall(); 309 | } 310 | 311 | async function sfuCall() { 312 | const sfuPeer = new RTCPeerConnection(rtcConfiguration); 313 | sfuPeerConnection = sfuPeer; 314 | sfuPeer.addEventListener("icecandidate", (e) => { 315 | sfuCandidate(e.candidate); 316 | }); 317 | sfuPeer.addEventListener("connectionstatechange", (e) => { 318 | console.log(sfuPeer.connectionState); 319 | }); 320 | window.sfuPeer = sfuPeer; 321 | 322 | localStream.getTracks().forEach((track) => { 323 | sfuPeer.addTrack(track, localStream); 324 | }); 325 | 326 | const offer = await sfuPeer.createOffer(); 327 | sfuPeer.setLocalDescription(offer); 328 | 329 | sendJSON({ 330 | type: "call", 331 | sdp: offer.sdp, 332 | isSFU: true, 333 | }); 334 | } 335 | 336 | function sfuCandidate(candidate, sessionId) { 337 | sendJSON({ 338 | type: "candidate", 339 | candidate, 340 | to: sessionId, 341 | isSFU: true, 342 | }); 343 | } 344 | 345 | window.enterSuccess = enterSuccess; 346 | 347 | window.integrateUser = integrateUser; 348 | 349 | function disconnect() { 350 | sfuPeerConnection.close(); 351 | // localPeers.forEach((peer) => { 352 | // peer.close(); 353 | // }); 354 | } 355 | 356 | window.disconnect = disconnect; 357 | 358 | // const button = document.body.querySelector('#btnLoadCam'); 359 | 360 | // async function loadCamera() { 361 | // try { 362 | // const stream = await navigator.mediaDevices.getUserMedia({ 363 | // audio: false, 364 | // video: true 365 | // }); 366 | // const videoTracks = stream.getVideoTracks() 367 | // myVideo.srcObject = stream 368 | // } catch (e) { 369 | // console.error(e); 370 | // } 371 | // } 372 | 373 | // button.addEventListener('click', () => { 374 | // loadCamera() 375 | // }) 376 | -------------------------------------------------------------------------------- /packages/webrtc-sample/snowpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | devOptions: { 3 | port: 4000, // 개발 서버를 실행할 포트 번호 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/webrtc-sample/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | box-sizing: border-box; 3 | } 4 | 5 | * { 6 | box-sizing: inherit; 7 | } 8 | 9 | video { 10 | width: 360px; 11 | height: 240px; 12 | border: 1px solid black; 13 | } 14 | --------------------------------------------------------------------------------