Browse Source

Initial SFU service

master
leiyun 1 month ago
commit
0439fb5701
27 changed files with 3364 additions and 0 deletions
  1. +10
    -0
      .dockerignore
  2. +35
    -0
      .env.example
  3. +28
    -0
      .env.live
  4. +13
    -0
      .gitignore
  5. +174
    -0
      DEPLOY.live.md
  6. +23
    -0
      Dockerfile
  7. +149
    -0
      README.md
  8. +41
    -0
      deploy/.env
  9. +40
    -0
      deploy/README.md
  10. +104
    -0
      deploy/install-from-cdn.sh
  11. +32
    -0
      deploy/nginx/yzx_oa_sfu.conf
  12. +45
    -0
      deploy/nginx/yzx_oa_sfu.multi-env.conf
  13. +15
    -0
      deploy/systemd/yzx-oa-sfu.service
  14. +13
    -0
      docker-compose.image.yml
  15. +13
    -0
      docker-compose.yml
  16. +1298
    -0
      package-lock.json
  17. +21
    -0
      package.json
  18. +21
    -0
      scripts/check-env.ps1
  19. +25
    -0
      scripts/docker-build-save.ps1
  20. +57
    -0
      scripts/docker-load-run.sh
  21. +18
    -0
      src/auth.js
  22. +139
    -0
      src/config.js
  23. +13
    -0
      src/logger.js
  24. +58
    -0
      src/mediasoupManager.js
  25. +136
    -0
      src/peer.js
  26. +695
    -0
      src/room.js
  27. +148
    -0
      src/server.js

+ 10
- 0
.dockerignore View File

@@ -0,0 +1,10 @@
node_modules
npm-debug.log*
yarn-error.log*
logs
*.log
*.tar
.env
.env.*
.git
.gitignore

+ 35
- 0
.env.example View File

@@ -0,0 +1,35 @@
# HTTP/WebSocket service.
NODE_ENV=production
LISTEN_IP=0.0.0.0
PORT=4443

# Public hostname is only used for logs/docs. Nginx usually terminates TLS/WSS.
PUBLIC_BASE_URL=https://oa.qbjjyyun.net/ws_webrtc_sfu_api

# Authentication is intentionally off for the first deployment.
AUTH_ENABLED=false

# mediasoup worker count. Leave empty to use CPU count.
MEDIASOUP_WORKERS=
MEDIASOUP_LOG_LEVEL=warn
MEDIASOUP_LOG_TAGS=info,ice,dtls,rtp,rtcp,bwe,score,simulcast,sctp

# Bindable IP on the SFU host.
MEDIASOUP_LISTEN_IP=0.0.0.0

# Required when MEDIASOUP_LISTEN_IP is 0.0.0.0 or the server is behind NAT.
# Use the public IP or public DNS name that browsers can reach.
MEDIASOUP_ANNOUNCED_ADDRESS=

# UDP/TCP ports for WebRTC media. Open this whole range on firewall/security group.
MEDIASOUP_RTC_MIN_PORT=40000
MEDIASOUP_RTC_MAX_PORT=49999

# Transport behavior.
MEDIASOUP_ENABLE_UDP=true
MEDIASOUP_ENABLE_TCP=true
MEDIASOUP_PREFER_UDP=true
MEDIASOUP_INITIAL_AVAILABLE_OUTGOING_BITRATE=1000000

# Empty rooms are closed after this delay.
ROOM_IDLE_TIMEOUT_MS=300000

+ 28
- 0
.env.live View File

@@ -0,0 +1,28 @@
NODE_ENV=production
LISTEN_IP=0.0.0.0
PORT=4443

PUBLIC_BASE_URL=https://oa.live.educlouddata.com/ws_webrtc_sfu_api_test
AUTH_ENABLED=false

# Test/live environment: keep one worker first so logs and diagnosis are simple.
MEDIASOUP_WORKERS=1
MEDIASOUP_LOG_LEVEL=warn
MEDIASOUP_LOG_TAGS=info,ice,dtls,rtp,rtcp,bwe,score,simulcast,sctp

MEDIASOUP_LISTEN_IP=0.0.0.0

# Use the public address that browser ICE candidates can reach.
# If oa.live.educlouddata.com is not the public address of this SFU host, replace it with the SFU server public IP.
MEDIASOUP_ANNOUNCED_ADDRESS=oa.live.educlouddata.com

# Smaller range for first live test. Open both UDP and TCP on firewall/security group.
MEDIASOUP_RTC_MIN_PORT=40000
MEDIASOUP_RTC_MAX_PORT=40100

MEDIASOUP_ENABLE_UDP=true
MEDIASOUP_ENABLE_TCP=true
MEDIASOUP_PREFER_UDP=true
MEDIASOUP_INITIAL_AVAILABLE_OUTGOING_BITRATE=1000000

ROOM_IDLE_TIMEOUT_MS=300000

+ 13
- 0
.gitignore View File

@@ -0,0 +1,13 @@
node_modules/
.env
.env.local
npm-debug.log*
yarn-error.log*
logs/
*.log
*.tar
*.tar.gz
*.tgz
*.zip

!deploy/.env

+ 174
- 0
DEPLOY.live.md View File

@@ -0,0 +1,174 @@
# Live 离线 Docker 部署

目标域名:

```text
https://oa.live.educlouddata.com/
```

SFU WebSocket 入口:

```text
wss://oa.live.educlouddata.com/ws_webrtc_sfu_api_test/?roomId=xxx&peerId=xxx
```

## 1. 本地打包并保存镜像

在 Windows 本地执行:

```powershell
cd D:\work\yzx_oa_sfu
.\scripts\docker-build-save.ps1
```

默认生成:

```text
D:\work\yzx_oa_sfu\yzx-oa-sfu_0.1.0-live_linux-amd64.tar
```

如果要手动执行,等价命令是:

```powershell
cd D:\work\yzx_oa_sfu
docker build --platform linux/amd64 -t yzx-oa-sfu:0.1.0-live .
docker save -o yzx-oa-sfu_0.1.0-live_linux-amd64.tar yzx-oa-sfu:0.1.0-live
```

## 2. 上传到服务器

上传以下文件到服务器同一个目录,例如 `/opt/yzx_oa_sfu`:

```text
yzx-oa-sfu_0.1.0-live_linux-amd64.tar
docker-compose.image.yml
.env
scripts/docker-load-run.sh(可选)
```

`.env` 使用本仓库的 `.env.live` 内容即可。服务器上可以这样放:

```bash
mkdir -p /opt/yzx_oa_sfu
cd /opt/yzx_oa_sfu
```

如果上传的是 `.env.live`,服务器上改名:

```bash
cp .env.live .env
```

## 3. 服务器 load 并启动

先确认服务器是否支持 Compose v2:

```bash
docker compose version
```

如果提示 `docker: 'compose' is not a docker command` 或类似 `unknown shorthand flag: 'f' in -f`,说明这台服务器没有 Docker Compose v2 插件。可以直接看本节后面的“不使用 compose 启动”。

```bash
cd /opt/yzx_oa_sfu
docker load -i yzx-oa-sfu_0.1.0-live_linux-amd64.tar
SFU_IMAGE=yzx-oa-sfu:0.1.0-live docker compose -f docker-compose.image.yml up -d
```

也可以使用脚本:

```bash
cd /opt/yzx_oa_sfu
bash scripts/docker-load-run.sh
```

脚本会自动判断当前服务器是否支持 `docker compose` / `docker-compose`。如果都没有,会退回到下面的 `docker run` 启动方式。

不使用 compose 启动:

```bash
cd /opt/yzx_oa_sfu
docker load -i yzx-oa-sfu_0.1.0-live_linux-amd64.tar
docker rm -f yzx-oa-sfu 2>/dev/null || true
docker run -d \
--name yzx-oa-sfu \
--restart unless-stopped \
--security-opt seccomp=unconfined \
--env-file .env \
-p 4443:4443/tcp \
-p 40000-40100:40000-40100/udp \
-p 40000-40100:40000-40100/tcp \
yzx-oa-sfu:0.1.0-live
```

如果容器反复 `Restarting (139)`,并且 `docker logs yzx-oa-sfu` 中出现 `uv_thread_create` / `WorkerThreadsTaskRunner` 相关断言,通常是旧 CentOS 7 + Docker 1.13 的默认 seccomp 与新 Node 运行时不兼容。请确认启动命令中包含:

```bash
--security-opt seccomp=unconfined
```

如果服务器安装的是旧版 `docker-compose` 命令,也可以用:

```bash
SFU_IMAGE=yzx-oa-sfu:0.1.0-live docker-compose -f docker-compose.image.yml up -d
```

## 4. Nginx 反代

在 `oa.live.educlouddata.com` 的 HTTPS server block 中加入:

```nginx
location /ws_webrtc_sfu_api_test/ {
proxy_pass http://127.0.0.1:4443/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}

location /sfu-healthz {
proxy_pass http://127.0.0.1:4443/healthz;
}
```

重载 Nginx:

```bash
nginx -t && nginx -s reload
```

## 5. 防火墙和安全组

需要放行:

```text
4443/tcp
40000-40100/udp
40000-40100/tcp
```

如果 `.env` 中修改了 `MEDIASOUP_RTC_MIN_PORT` / `MEDIASOUP_RTC_MAX_PORT`,防火墙和 `docker-compose.image.yml` 映射范围要跟随 `.env`。

## 6. 检查命令

```bash
docker ps | grep yzx-oa-sfu
docker logs -f yzx-oa-sfu
curl http://127.0.0.1:4443/healthz
curl https://oa.live.educlouddata.com/sfu-healthz
```

## 7. OA 前端配置

`oa/.env.live` 已配置:

```text
VITE_SFU_WS_URL = 'wss://oa.live.educlouddata.com/ws_webrtc_sfu_api_test'
```

如果使用其他 `.env.*` 构建 OA,也需要加同样的 `VITE_SFU_WS_URL`。

+ 23
- 0
Dockerfile View File

@@ -0,0 +1,23 @@
FROM node:22-bullseye-slim

WORKDIR /app

ENV NODE_ENV=production

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
python3 \
python3-pip \
python-is-python3 \
&& rm -rf /var/lib/apt/lists/*

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

EXPOSE 4443

CMD ["npm", "start"]

+ 149
- 0
README.md View File

@@ -0,0 +1,149 @@
# yzx_oa_sfu

这是给 OA 前端 `src/views/live/room_sfu.vue` 准备的 mediasoup + protoo SFU 服务。当前版本先兼容现有前端协议,不做 token 校验。

## 版本

- Node.js >= 22
- mediasoup 3.21.2
- protoo-server 4.0.8

## 本地启动

```bash
npm install
copy .env.example .env
npm start
```

本地健康检查:

```bash
curl http://127.0.0.1:4443/healthz
```

WebSocket 入口兼容这类地址:

```text
wss://你的域名/ws_webrtc_sfu_api/?roomId=xxx&peerId=xxx
wss://你的域名/ws_webrtc_sfu_api_test/?roomId=xxx&peerId=xxx
```

如果直接连服务端端口,也可以:

```text
ws://127.0.0.1:4443/?roomId=xxx&peerId=xxx
```

## 部署要点

1. Nginx 负责 HTTPS/WSS,反向代理到本服务的 `PORT`。
2. 防火墙要放行 `MEDIASOUP_RTC_MIN_PORT` 到 `MEDIASOUP_RTC_MAX_PORT` 的 UDP,建议 TCP 也放行作为兜底。
3. 如果服务在公网 IP 机器上,`MEDIASOUP_ANNOUNCED_ADDRESS` 填公网 IP 或域名。
4. 如果服务在内网/NAT 后面,`MEDIASOUP_ANNOUNCED_ADDRESS` 必须填浏览器可达的公网地址。
5. 第一阶段 `AUTH_ENABLED=false`,只校验 `roomId` 和 `peerId` 是否存在。

## Docker 离线部署

可以在 Windows 本地打包镜像,保存为 tar,服务器只负责 `docker load` 和启动容器。Windows 本机的 Node 版本不影响镜像内容,Dockerfile 使用的是 `node:22-bullseye-slim`。

live 环境完整部署步骤见:

```text
DEPLOY.live.md
```

本地打包:

```powershell
cd D:\work\yzx_oa_sfu
.\scripts\docker-build-save.ps1
```

如果服务器是常见 x86_64 Linux,上面的默认平台 `linux/amd64` 就可以。生成文件:

```text
yzx-oa-sfu_0.1.0-live_linux-amd64.tar
```

把这些文件传到服务器同一个目录:

```text
yzx-oa-sfu_0.1.0-live_linux-amd64.tar
docker-compose.image.yml
.env
scripts/docker-load-run.sh(可选)
```

测试环境 `.env` 建议:

```text
NODE_ENV=production
LISTEN_IP=0.0.0.0
PORT=4443
PUBLIC_BASE_URL=https://oa.live.educlouddata.com/ws_webrtc_sfu_api_test
AUTH_ENABLED=false
MEDIASOUP_WORKERS=1
MEDIASOUP_LISTEN_IP=0.0.0.0
MEDIASOUP_ANNOUNCED_ADDRESS=oa.live.educlouddata.com
MEDIASOUP_RTC_MIN_PORT=40000
MEDIASOUP_RTC_MAX_PORT=40100
MEDIASOUP_ENABLE_UDP=true
MEDIASOUP_ENABLE_TCP=true
MEDIASOUP_PREFER_UDP=true
MEDIASOUP_INITIAL_AVAILABLE_OUTGOING_BITRATE=1000000
ROOM_IDLE_TIMEOUT_MS=300000
```

服务器启动:

```bash
docker load -i yzx-oa-sfu_0.1.0-live_linux-amd64.tar
SFU_IMAGE=yzx-oa-sfu:0.1.0-live docker compose -f docker-compose.image.yml up -d
```

检查:

```bash
docker logs -f yzx-oa-sfu
curl http://127.0.0.1:4443/healthz
```

测试环境 OA 域名是 `https://oa.live.educlouddata.com/` 时,Nginx 需要把 `/ws_webrtc_sfu_api_test/` 反代到容器映射出来的 `127.0.0.1:4443`。

## 当前兼容的前端能力

- 加入房间、成员上下线
- 音频/视频/屏幕共享生产和消费
- DataChannel `chat` 通知,用于前端刷新业务聊天列表
- 主持人远端静音、全体静音、解除静音
- 基础 stats 接口
- 部分在线课堂扩展命令按 target 透传广播,后续可以接业务权限和状态

## 前端接入建议

当前 OA 前端的 `src/utils/sfu/urlFactory.js` 里写死了域名和路径。服务部署好后,建议把它改成环境变量:

```js
const baseUrl = import.meta.env.VITE_SFU_WS_URL || `wss://${window.location.hostname}/ws_webrtc_sfu_api`
const url = `${baseUrl}/?roomId=${roomId}&peerId=${peerId}`
```

对应 `.env.*`:

```text
VITE_SFU_WS_URL='wss://oa.qbjjyyun.net/ws_webrtc_sfu_api'
```

测试环境 `oa.live.educlouddata.com` 对应:

```text
VITE_SFU_WS_URL='wss://oa.live.educlouddata.com/ws_webrtc_sfu_api_test'
```

## 暂未做的事

- token/业务权限校验
- TURN/STUN 下发
- 多进程跨房间横向扩容
- 录制、旁路推流

+ 41
- 0
deploy/.env View File

@@ -0,0 +1,41 @@
NODE_ENV=production
LISTEN_IP=0.0.0.0
PORT=4443

AUTH_ENABLED=false

MEDIASOUP_WORKERS=1
MEDIASOUP_LOG_LEVEL=warn
MEDIASOUP_LOG_TAGS=info,ice,dtls,rtp,rtcp,bwe,score,simulcast,sctp

MEDIASOUP_LISTEN_IP=0.0.0.0

# production: oa.qbjjyyun.net
PUBLIC_BASE_URL=https://oa.qbjjyyun.net/ws_webrtc_sfu_api
MEDIASOUP_ANNOUNCED_ADDRESS=oa.qbjjyyun.net

# cszxy: oa.educlouddata.com
# PUBLIC_BASE_URL=https://oa.educlouddata.com/ws_webrtc_sfu_api
# MEDIASOUP_ANNOUNCED_ADDRESS=oa.educlouddata.com

# jpggx: oa.61gx.com
# PUBLIC_BASE_URL=https://oa.61gx.com/ws_webrtc_sfu_api
# MEDIASOUP_ANNOUNCED_ADDRESS=oa.61gx.com

# nanping: oa.npzx.org.cn
# PUBLIC_BASE_URL=https://oa.npzx.org.cn/ws_webrtc_sfu_api
# MEDIASOUP_ANNOUNCED_ADDRESS=oa.npzx.org.cn

# live/test: oa.live.educlouddata.com
# PUBLIC_BASE_URL=https://oa.live.educlouddata.com/ws_webrtc_sfu_api_test
# MEDIASOUP_ANNOUNCED_ADDRESS=oa.live.educlouddata.com

MEDIASOUP_RTC_MIN_PORT=40000
MEDIASOUP_RTC_MAX_PORT=40100

MEDIASOUP_ENABLE_UDP=true
MEDIASOUP_ENABLE_TCP=true
MEDIASOUP_PREFER_UDP=true
MEDIASOUP_INITIAL_AVAILABLE_OUTGOING_BITRATE=1000000

ROOM_IDLE_TIMEOUT_MS=300000

+ 40
- 0
deploy/README.md View File

@@ -0,0 +1,40 @@
# CDN 离线镜像一键部署

默认从 CDN 下载镜像包并启动 Docker 容器:

```bash
cd /opt/yzx_oa_sfu/deploy
bash install-from-cdn.sh
```

这个脚本直接使用 `docker run` 启动,不需要复制 `docker-compose.image.yml`。

默认镜像地址:

```text
https://oa-edu-1259243469.cos.ap-chengdu.myqcloud.com/public/file/oa_sfu_docker/yzx-oa-sfu_0.1.0-live_linux-amd64.tar
```

脚本默认安装目录是 `/opt/yzx_oa_sfu`。如果要换目录:

```bash
INSTALL_DIR=/data/yzx_oa_sfu bash install-from-cdn.sh
```

首次执行时会把 `deploy/.env` 复制到安装目录的 `.env`。上线前请按目标环境打开对应域名配置。

检查:

```bash
docker ps | grep yzx-oa-sfu
docker logs -f yzx-oa-sfu
curl http://127.0.0.1:4443/healthz
```

服务器和安全组需要放行:

```text
4443/tcp
40000-40100/udp
40000-40100/tcp
```

+ 104
- 0
deploy/install-from-cdn.sh View File

@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail

IMAGE_URL="${IMAGE_URL:-https://oa-edu-1259243469.cos.ap-chengdu.myqcloud.com/public/file/oa_sfu_docker/yzx-oa-sfu_0.1.0-live_linux-amd64.tar}"
IMAGE_TAR="${IMAGE_TAR:-yzx-oa-sfu_0.1.0-live_linux-amd64.tar}"
IMAGE_NAME="${SFU_IMAGE:-yzx-oa-sfu:0.1.0-live}"
CONTAINER_NAME="${CONTAINER_NAME:-yzx-oa-sfu}"
INSTALL_DIR="${INSTALL_DIR:-/opt/yzx_oa_sfu}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

get_env() {
local key="$1"
local default_value="$2"
local line value

line="$(grep -E "^${key}=" .env | tail -n 1 || true)"
value="${line#*=}"
value="${value%$'\r'}"
value="${value#\'}"
value="${value%\'}"
value="${value#\"}"
value="${value%\"}"

if [ -n "$value" ]; then
printf "%s" "$value"
else
printf "%s" "$default_value"
fi
}

download_image() {
if [ -f "$IMAGE_TAR" ]; then
echo "Image tar already exists: $IMAGE_TAR"
return
fi

echo "Downloading image tar:"
echo " $IMAGE_URL"

if command -v curl >/dev/null 2>&1; then
curl -fL --retry 3 --retry-delay 3 -o "$IMAGE_TAR" "$IMAGE_URL"
elif command -v wget >/dev/null 2>&1; then
wget -O "$IMAGE_TAR" "$IMAGE_URL"
else
echo "Missing curl/wget. Please install one of them or upload $IMAGE_TAR manually." >&2
exit 1
fi
}

if ! command -v docker >/dev/null 2>&1; then
echo "Docker is not installed or not in PATH." >&2
exit 1
fi

mkdir -p "$INSTALL_DIR"
cd "$INSTALL_DIR"

if [ ! -f ".env" ]; then
if [ -f "$SCRIPT_DIR/.env" ]; then
cp "$SCRIPT_DIR/.env" .env
echo "Created $INSTALL_DIR/.env from deploy/.env."
echo "Please review .env before production use."
else
echo "Missing .env. Put deploy/.env next to this script or create $INSTALL_DIR/.env." >&2
exit 1
fi
fi

download_image

echo "Loading Docker image..."
docker load -i "$IMAGE_TAR"

PORT="$(get_env PORT 4443)"
RTC_MIN_PORT="$(get_env MEDIASOUP_RTC_MIN_PORT 40000)"
RTC_MAX_PORT="$(get_env MEDIASOUP_RTC_MAX_PORT 40100)"

echo "Restarting container: $CONTAINER_NAME"
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true

docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
--security-opt seccomp=unconfined \
--env-file .env \
-p "${PORT}:${PORT}/tcp" \
-p "${RTC_MIN_PORT}-${RTC_MAX_PORT}:${RTC_MIN_PORT}-${RTC_MAX_PORT}/udp" \
-p "${RTC_MIN_PORT}-${RTC_MAX_PORT}:${RTC_MIN_PORT}-${RTC_MAX_PORT}/tcp" \
"$IMAGE_NAME"

echo
docker ps --filter "name=${CONTAINER_NAME}"
echo
echo "Health check:"
if command -v curl >/dev/null 2>&1; then
curl -fsS "http://127.0.0.1:${PORT}/healthz" || true
echo
else
echo "curl not found; run: curl http://127.0.0.1:${PORT}/healthz"
fi

echo
echo "Done. Logs:"
echo " docker logs -f $CONTAINER_NAME"

+ 32
- 0
deploy/nginx/yzx_oa_sfu.conf View File

@@ -0,0 +1,32 @@
# Put this inside the existing HTTPS server block for oa.qbjjyyun.net.
# The trailing slash keeps the query string: ?roomId=xxx&peerId=xxx

location /ws_webrtc_sfu_api/ {
proxy_pass http://127.0.0.1:4443/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}

location /ws_webrtc_sfu_api_test/ {
proxy_pass http://127.0.0.1:4443/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}

location /sfu-healthz {
proxy_pass http://127.0.0.1:4443/healthz;
}

+ 45
- 0
deploy/nginx/yzx_oa_sfu.multi-env.conf View File

@@ -0,0 +1,45 @@
# Copy the location blocks into the existing HTTPS server block of the target OA domain.
# Only enable the domain section that matches the current server.

# production: oa.qbjjyyun.net
# server_name oa.qbjjyyun.net;

# cszxy: oa.educlouddata.com
# server_name oa.educlouddata.com;

# jpggx: oa.61gx.com
# server_name oa.61gx.com;

# nanping: oa.npzx.org.cn
# server_name oa.npzx.org.cn;

location /ws_webrtc_sfu_api/ {
proxy_pass http://127.0.0.1:4443/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}

location /sfu-healthz {
proxy_pass http://127.0.0.1:4443/healthz;
}

# live/test only. Enable inside oa.live.educlouddata.com if this environment still uses _test.
# location /ws_webrtc_sfu_api_test/ {
# proxy_pass http://127.0.0.1:4443/;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection "upgrade";
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_read_timeout 86400;
# proxy_send_timeout 86400;
# }

+ 15
- 0
deploy/systemd/yzx-oa-sfu.service View File

@@ -0,0 +1,15 @@
[Unit]
Description=YZX OA mediasoup SFU
After=network.target

[Service]
Type=simple
WorkingDirectory=/opt/yzx_oa_sfu
EnvironmentFile=/opt/yzx_oa_sfu/.env
ExecStart=/usr/bin/npm start
Restart=always
RestartSec=5
LimitNOFILE=1048576

[Install]
WantedBy=multi-user.target

+ 13
- 0
docker-compose.image.yml View File

@@ -0,0 +1,13 @@
services:
yzx-oa-sfu:
image: "${SFU_IMAGE:-yzx-oa-sfu:0.1.0-live}"
container_name: yzx-oa-sfu
restart: unless-stopped
security_opt:
- seccomp=unconfined
env_file:
- .env
ports:
- "${PORT:-4443}:${PORT:-4443}/tcp"
- "${MEDIASOUP_RTC_MIN_PORT:-40000}-${MEDIASOUP_RTC_MAX_PORT:-40100}:${MEDIASOUP_RTC_MIN_PORT:-40000}-${MEDIASOUP_RTC_MAX_PORT:-40100}/udp"
- "${MEDIASOUP_RTC_MIN_PORT:-40000}-${MEDIASOUP_RTC_MAX_PORT:-40100}:${MEDIASOUP_RTC_MIN_PORT:-40000}-${MEDIASOUP_RTC_MAX_PORT:-40100}/tcp"

+ 13
- 0
docker-compose.yml View File

@@ -0,0 +1,13 @@
services:
yzx-oa-sfu:
build: .
container_name: yzx-oa-sfu
restart: unless-stopped
security_opt:
- seccomp=unconfined
env_file:
- .env
ports:
- "${PORT:-4443}:${PORT:-4443}/tcp"
- "${MEDIASOUP_RTC_MIN_PORT:-40000}-${MEDIASOUP_RTC_MAX_PORT:-40100}:${MEDIASOUP_RTC_MIN_PORT:-40000}-${MEDIASOUP_RTC_MAX_PORT:-40100}/udp"
- "${MEDIASOUP_RTC_MIN_PORT:-40000}-${MEDIASOUP_RTC_MAX_PORT:-40100}:${MEDIASOUP_RTC_MIN_PORT:-40000}-${MEDIASOUP_RTC_MAX_PORT:-40100}/tcp"

+ 1298
- 0
package-lock.json
File diff suppressed because it is too large
View File


+ 21
- 0
package.json View File

@@ -0,0 +1,21 @@
{
"name": "yzx-oa-sfu",
"version": "0.1.0",
"private": true,
"description": "mediasoup/protoo SFU service for YZX OA live rooms",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"check": "node --check src/server.js && node --check src/config.js && node --check src/mediasoupManager.js && node --check src/room.js && node --check src/peer.js && node --check src/auth.js && node --check src/logger.js"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"dotenv": "^17.2.3",
"express": "^5.1.0",
"mediasoup": "3.21.2",
"protoo-server": "4.0.8"
},
"devDependencies": {}
}

+ 21
- 0
scripts/check-env.ps1 View File

@@ -0,0 +1,21 @@
$ErrorActionPreference = 'Stop'

$envFile = Join-Path $PSScriptRoot '..\.env'

if (!(Test-Path $envFile)) {
Write-Host '.env not found. Copy .env.example to .env first.' -ForegroundColor Yellow
exit 1
}

Get-Content $envFile | ForEach-Object {
if ($_ -match '^\s*#' -or $_ -notmatch '=') { return }
$name, $value = $_ -split '=', 2
[Environment]::SetEnvironmentVariable($name.Trim(), $value.Trim(), 'Process')
}

if ($env:MEDIASOUP_LISTEN_IP -in @('0.0.0.0', '::') -and !$env:MEDIASOUP_ANNOUNCED_ADDRESS) {
Write-Host 'MEDIASOUP_ANNOUNCED_ADDRESS is required when MEDIASOUP_LISTEN_IP is 0.0.0.0 or ::' -ForegroundColor Red
exit 1
}

Write-Host 'Environment looks OK.' -ForegroundColor Green

+ 25
- 0
scripts/docker-build-save.ps1 View File

@@ -0,0 +1,25 @@
param(
[string]$ImageName = "yzx-oa-sfu:0.1.0-live",
[string]$Platform = "linux/amd64",
[string]$Output = "yzx-oa-sfu_0.1.0-live_linux-amd64.tar"
)

$ErrorActionPreference = "Stop"
$root = Resolve-Path (Join-Path $PSScriptRoot "..")

Push-Location $root
try {
docker build --platform $Platform -t $ImageName .
if ($LASTEXITCODE -ne 0) {
throw "docker build failed with exit code $LASTEXITCODE"
}

docker save -o $Output $ImageName
if ($LASTEXITCODE -ne 0) {
throw "docker save failed with exit code $LASTEXITCODE"
}

Write-Host "Saved $ImageName to $Output" -ForegroundColor Green
} finally {
Pop-Location
}

+ 57
- 0
scripts/docker-load-run.sh View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail

IMAGE_TAR="${1:-yzx-oa-sfu_0.1.0-live_linux-amd64.tar}"
IMAGE_NAME="${SFU_IMAGE:-yzx-oa-sfu:0.1.0-live}"
CONTAINER_NAME="${CONTAINER_NAME:-yzx-oa-sfu}"

get_env() {
local key="$1"
local default_value="$2"
local line value

line="$(grep -E "^${key}=" .env | tail -n 1 || true)"
value="${line#*=}"
value="${value%$'\r'}"
value="${value#\'}"
value="${value%\'}"
value="${value#\"}"
value="${value%\"}"

if [ -n "$value" ]; then
printf "%s" "$value"
else
printf "%s" "$default_value"
fi
}

if [ ! -f ".env" ]; then
echo "Missing .env in current directory." >&2
exit 1
fi

docker load -i "$IMAGE_TAR"

if docker compose version >/dev/null 2>&1; then
SFU_IMAGE="$IMAGE_NAME" docker compose -f docker-compose.image.yml up -d
docker compose -f docker-compose.image.yml ps
elif command -v docker-compose >/dev/null 2>&1; then
SFU_IMAGE="$IMAGE_NAME" docker-compose -f docker-compose.image.yml up -d
docker-compose -f docker-compose.image.yml ps
else
PORT="$(get_env PORT 4443)"
RTC_MIN_PORT="$(get_env MEDIASOUP_RTC_MIN_PORT 40000)"
RTC_MAX_PORT="$(get_env MEDIASOUP_RTC_MAX_PORT 40100)"

docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
--security-opt seccomp=unconfined \
--env-file .env \
-p "${PORT}:${PORT}/tcp" \
-p "${RTC_MIN_PORT}-${RTC_MAX_PORT}:${RTC_MIN_PORT}-${RTC_MAX_PORT}/udp" \
-p "${RTC_MIN_PORT}-${RTC_MAX_PORT}:${RTC_MIN_PORT}-${RTC_MAX_PORT}/tcp" \
"$IMAGE_NAME"
docker ps --filter "name=${CONTAINER_NAME}"
fi

+ 18
- 0
src/auth.js View File

@@ -0,0 +1,18 @@
const config = require('./config');

async function authenticateConnection({ roomId, peerId }) {
if (!roomId || !peerId) {
return { ok: false, statusCode: 400, reason: 'roomId and peerId are required' };
}

if (!config.auth.enabled) {
return { ok: true };
}

// Reserved for later business-token validation.
return { ok: true };
}

module.exports = {
authenticateConnection
};

+ 139
- 0
src/config.js View File

@@ -0,0 +1,139 @@
require('dotenv').config({ quiet: true });

const os = require('node:os');

function toNumber(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}

function toBoolean(value, fallback = false) {
if (value === undefined || value === null || value === '') return fallback;
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
}

function toList(value) {
if (!value) return [];
return String(value)
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}

const rtcMinPort = toNumber(process.env.MEDIASOUP_RTC_MIN_PORT, 40000);
const rtcMaxPort = toNumber(process.env.MEDIASOUP_RTC_MAX_PORT, 49999);
const listenIp = process.env.MEDIASOUP_LISTEN_IP || '0.0.0.0';
const announcedAddress = process.env.MEDIASOUP_ANNOUNCED_ADDRESS || undefined;

const listenInfoBase = {
ip: listenIp,
announcedAddress,
portRange: {
min: rtcMinPort,
max: rtcMaxPort
}
};

const listenInfos = [];

if (toBoolean(process.env.MEDIASOUP_ENABLE_UDP, true)) {
listenInfos.push({
...listenInfoBase,
protocol: 'udp'
});
}

if (toBoolean(process.env.MEDIASOUP_ENABLE_TCP, true)) {
listenInfos.push({
...listenInfoBase,
protocol: 'tcp'
});
}

module.exports = {
env: process.env.NODE_ENV || 'development',
listenIp: process.env.LISTEN_IP || '0.0.0.0',
port: toNumber(process.env.PORT, 4443),
publicBaseUrl: process.env.PUBLIC_BASE_URL || '',
auth: {
enabled: toBoolean(process.env.AUTH_ENABLED, false)
},
room: {
idleTimeoutMs: toNumber(process.env.ROOM_IDLE_TIMEOUT_MS, 300000)
},
mediasoup: {
workerCount: toNumber(process.env.MEDIASOUP_WORKERS, os.cpus().length || 1),
worker: {
logLevel: process.env.MEDIASOUP_LOG_LEVEL || 'warn',
logTags: toList(
process.env.MEDIASOUP_LOG_TAGS ||
'info,ice,dtls,rtp,rtcp,bwe,score,simulcast,sctp'
),
// Deprecated in mediasoup, but harmless and useful for older deployment notes.
rtcMinPort,
rtcMaxPort
},
router: {
mediaCodecs: [
{
kind: 'audio',
mimeType: 'audio/opus',
clockRate: 48000,
channels: 2
},
{
kind: 'video',
mimeType: 'video/VP8',
clockRate: 90000,
parameters: {
'x-google-start-bitrate': 1000
}
},
{
kind: 'video',
mimeType: 'video/VP9',
clockRate: 90000,
parameters: {
'profile-id': 2,
'x-google-start-bitrate': 1000
}
},
{
kind: 'video',
mimeType: 'video/H264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '4d0032',
'level-asymmetry-allowed': 1,
'x-google-start-bitrate': 1000
}
},
{
kind: 'video',
mimeType: 'video/H264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '42e01f',
'level-asymmetry-allowed': 1,
'x-google-start-bitrate': 1000
}
}
]
},
webRtcTransport: {
listenInfos,
enableUdp: toBoolean(process.env.MEDIASOUP_ENABLE_UDP, true),
enableTcp: toBoolean(process.env.MEDIASOUP_ENABLE_TCP, true),
preferUdp: toBoolean(process.env.MEDIASOUP_PREFER_UDP, true),
initialAvailableOutgoingBitrate: toNumber(
process.env.MEDIASOUP_INITIAL_AVAILABLE_OUTGOING_BITRATE,
1000000
),
enableSctp: true,
maxReceiveMessageSize: 262144,
maxSendMessageSize: 262144
}
}
};

+ 13
- 0
src/logger.js View File

@@ -0,0 +1,13 @@
function line(level, args) {
const now = new Date().toISOString();
console[level](`[${now}] [${level.toUpperCase()}]`, ...args);
}

module.exports = {
debug: (...args) => {
if (process.env.DEBUG === 'true') line('debug', args);
},
info: (...args) => line('log', args),
warn: (...args) => line('warn', args),
error: (...args) => line('error', args)
};

+ 58
- 0
src/mediasoupManager.js View File

@@ -0,0 +1,58 @@
const mediasoup = require('mediasoup');
const config = require('./config');
const logger = require('./logger');

class MediasoupManager {
constructor() {
this.workers = [];
this.nextWorkerIndex = 0;
}

async start() {
const count = Math.max(1, config.mediasoup.workerCount);

for (let index = 0; index < count; index += 1) {
const worker = await mediasoup.createWorker(config.mediasoup.worker);

worker.on('died', () => {
logger.error('mediasoup worker died, exiting in 2 seconds', {
pid: worker.pid
});
setTimeout(() => process.exit(1), 2000);
});

this.workers.push(worker);
logger.info('mediasoup worker created', { index, pid: worker.pid });
}
}

getWorker() {
if (!this.workers.length) {
throw new Error('mediasoup workers are not ready');
}

const worker = this.workers[this.nextWorkerIndex];
this.nextWorkerIndex = (this.nextWorkerIndex + 1) % this.workers.length;

return worker;
}

async createRouter(appData = {}) {
const worker = this.getWorker();

return worker.createRouter({
mediaCodecs: config.mediasoup.router.mediaCodecs,
appData
});
}

async close() {
for (const worker of this.workers) {
worker.close();
}

this.workers = [];
}
}

module.exports = MediasoupManager;

+ 136
- 0
src/peer.js View File

@@ -0,0 +1,136 @@
class Peer {
constructor({ id, protooPeer }) {
this.id = id;
this.protooPeer = protooPeer;
this.joined = false;
this.displayName = id;
this.device = {};
this.rtpCapabilities = null;
this.sctpCapabilities = null;
this.schoolName = '';
this.relationName = '';
this.whiteboard_width = undefined;
this.whiteboard_height = undefined;
this.isChat = false;
this.isShareWebcam = false;
this.chalkStatus = 'off';

this.transports = new Map();
this.producers = new Map();
this.consumers = new Map();
this.dataProducers = new Map();
this.dataConsumers = new Map();
}

get closed() {
return this.protooPeer.closed;
}

getInfo() {
return {
id: this.id,
displayName: this.displayName,
device: this.device,
schoolName: this.schoolName,
isChat: this.isChat,
relationName: this.relationName,
isShareWebcam: this.isShareWebcam,
chalkStatus: this.chalkStatus,
whiteboard_width: this.whiteboard_width,
whiteboard_height: this.whiteboard_height,
consumers: [],
dataConsumers: [],
consumersObj: []
};
}

notify(method, data = {}) {
if (this.closed) return Promise.resolve();

return this.protooPeer.notify(method, data).catch(() => undefined);
}

request(method, data = {}) {
if (this.closed) {
return Promise.reject(new Error(`peer ${this.id} is closed`));
}

return this.protooPeer.request(method, data);
}

getTransport(transportId) {
const transport = this.transports.get(transportId);

if (!transport) {
throw new Error(`transport not found [transportId:${transportId}]`);
}

return transport;
}

getConsumer(consumerId) {
const consumer = this.consumers.get(consumerId);

if (!consumer) {
throw new Error(`consumer not found [consumerId:${consumerId}]`);
}

return consumer;
}

getProducer(producerId) {
const producer = this.producers.get(producerId);

if (!producer) {
throw new Error(`producer not found [producerId:${producerId}]`);
}

return producer;
}

getDataProducer(dataProducerId) {
const dataProducer = this.dataProducers.get(dataProducerId);

if (!dataProducer) {
throw new Error(`dataProducer not found [dataProducerId:${dataProducerId}]`);
}

return dataProducer;
}

getDataConsumer(dataConsumerId) {
const dataConsumer = this.dataConsumers.get(dataConsumerId);

if (!dataConsumer) {
throw new Error(`dataConsumer not found [dataConsumerId:${dataConsumerId}]`);
}

return dataConsumer;
}

getRecvTransport() {
for (const transport of this.transports.values()) {
if (transport.appData && transport.appData.consuming) return transport;
}

return undefined;
}

close() {
for (const dataConsumer of this.dataConsumers.values()) dataConsumer.close();
for (const consumer of this.consumers.values()) consumer.close();
for (const dataProducer of this.dataProducers.values()) dataProducer.close();
for (const producer of this.producers.values()) producer.close();
for (const transport of this.transports.values()) transport.close();

this.dataConsumers.clear();
this.consumers.clear();
this.dataProducers.clear();
this.producers.clear();
this.transports.clear();

if (!this.closed) this.protooPeer.close();
}
}

module.exports = Peer;

+ 695
- 0
src/room.js View File

@@ -0,0 +1,695 @@
const protoo = require('protoo-server');
const config = require('./config');
const logger = require('./logger');
const Peer = require('./peer');

const FORWARDED_COMMANDS = new Set([
'askSpeak',
'agreeSpeak',
'endSpeak',
'handUp',
'agreeTeaSpeak',
'signIn',
'changePeerTrack',
'toggleChalk',
'toggleShareWebcam',
'startClass',
'roomSetting',
'exitOtherRoom',
'overClass',
'changeWhiteboardWidthAndHeight',
'setWhiteboardScale'
]);

class Room {
constructor({ id, router, onEmpty }) {
this.id = id;
this.router = router;
this.onEmpty = onEmpty;
this.peers = new Map();
this.protooRoom = new protoo.Room();
this.idleTimer = null;
this.closed = false;
this.activeSpeakerObserver = null;
}

async init() {
this.activeSpeakerObserver = await this.router.createActiveSpeakerObserver({
interval: 500
});

this.activeSpeakerObserver.on('dominantspeaker', ({ producer }) => {
const peerId = producer.appData && producer.appData.peerId;

if (peerId) {
this.broadcast('activeSpeaker', { peerId });
}
});
}

handleConnection({ peerId, transport }) {
if (this.closed) {
transport.close();
return;
}

this.clearIdleTimer();

const oldPeer = this.peers.get(peerId);
if (oldPeer) {
logger.warn('closing old peer with the same id', { roomId: this.id, peerId });
oldPeer.close();
}

if (this.protooRoom.hasPeer(peerId)) {
this.protooRoom.getPeer(peerId).close();
}

const protooPeer = this.protooRoom.createPeer(peerId, transport);
const peer = new Peer({ id: peerId, protooPeer });
this.peers.set(peerId, peer);

protooPeer.on('request', (request, accept, reject) => {
this.handlePeerRequest({ peer, request, accept, reject }).catch((error) => {
logger.error('protoo request failed', {
roomId: this.id,
peerId,
method: request.method,
error: error.stack || error.message
});
reject(500, error.message);
});
});

protooPeer.on('close', () => {
this.closePeer(peer);
});
}

async handlePeerRequest({ peer, request, accept, reject }) {
const data = request.data || {};

switch (request.method) {
case 'getRouterRtpCapabilities': {
accept(this.router.rtpCapabilities);
break;
}

case 'createWebRtcTransport': {
const transportInfo = await this.createWebRtcTransport({
peer,
producing: Boolean(data.producing),
consuming: Boolean(data.consuming),
forceTcp: Boolean(data.forceTcp),
enableSctp: Boolean(data.sctpCapabilities)
});

accept(transportInfo);
break;
}

case 'connectWebRtcTransport': {
const transport = peer.getTransport(data.transportId);
await transport.connect({ dtlsParameters: data.dtlsParameters });
accept();
break;
}

case 'join': {
const joinedPeers = this.getJoinedPeers(peer.id).map((item) => item.getInfo());

peer.joined = true;
peer.displayName = data.displayName || peer.id;
peer.device = data.device || {};
peer.rtpCapabilities = data.rtpCapabilities;
peer.sctpCapabilities = data.sctpCapabilities;
peer.relationName = data.relationName || '';
peer.schoolName = data.schoolName || '';
peer.isChat = Boolean(data.isChat);
peer.chalkStatus = data.chalkStatus || 'off';
peer.isShareWebcam = Boolean(data.isShareWebcam);
peer.whiteboard_width = data.whiteboard_width;
peer.whiteboard_height = data.whiteboard_height;

accept({ peers: joinedPeers });

this.broadcast('newPeer', peer.getInfo(), { excludePeerId: peer.id });
setImmediate(() => this.createExistingConsumersForPeer(peer));
break;
}

case 'produce': {
const producer = await this.createProducer({ peer, data });
accept({ id: producer.id });
setImmediate(() => this.createConsumersForProducer(peer, producer));
break;
}

case 'produceData': {
const dataProducer = await this.createDataProducer({ peer, data });
accept({ id: dataProducer.id });
setImmediate(() => this.createDataConsumersForDataProducer(peer, dataProducer));
break;
}

case 'closeProducer': {
const producer = peer.getProducer(data.producerId);
producer.close();
peer.producers.delete(producer.id);
accept();
break;
}

case 'pauseProducer': {
await peer.getProducer(data.producerId).pause();
accept();
break;
}

case 'resumeProducer': {
await peer.getProducer(data.producerId).resume();
accept();
break;
}

case 'pauseConsumer': {
await peer.getConsumer(data.consumerId).pause();
accept();
break;
}

case 'resumeConsumer': {
const consumer = peer.getConsumer(data.consumerId);
await consumer.resume();
if (consumer.kind === 'video') await consumer.requestKeyFrame();
accept();
break;
}

case 'restartIce': {
const transport = peer.getTransport(data.transportId);
const iceParameters = await transport.restartIce();
accept(iceParameters);
break;
}

case 'setConsumerPreferredLayers': {
await peer.getConsumer(data.consumerId).setPreferredLayers({
spatialLayer: data.spatialLayer,
temporalLayer: data.temporalLayer
});
accept();
break;
}

case 'setConsumerPriority': {
await peer.getConsumer(data.consumerId).setPriority(data.priority);
accept();
break;
}

case 'requestConsumerKeyFrame': {
await peer.getConsumer(data.consumerId).requestKeyFrame();
accept();
break;
}

case 'getTransportStats': {
accept(await peer.getTransport(data.transportId).getStats());
break;
}

case 'getProducerStats': {
accept(await peer.getProducer(data.producerId).getStats());
break;
}

case 'getConsumerStats': {
accept(await peer.getConsumer(data.consumerId).getStats());
break;
}

case 'getDataProducerStats': {
accept(await peer.getDataProducer(data.dataProducerId).getStats());
break;
}

case 'getDataConsumerStats': {
accept(await peer.getDataConsumer(data.dataConsumerId).getStats());
break;
}

case 'changeDisplayName': {
const oldDisplayName = peer.displayName;
peer.displayName = data.displayName || peer.displayName;
accept();
this.broadcast(
'peerDisplayNameChanged',
{ peerId: peer.id, displayName: peer.displayName, oldDisplayName },
{ excludePeerId: peer.id }
);
break;
}

case 'changeIsChat': {
const oldIsChat = peer.isChat;
peer.isChat = Boolean(data.isChat);
accept();
this.broadcast(
'peerIsChatChanged',
{ peerId: peer.id, isChat: peer.isChat, oldIsChat },
{ excludePeerId: peer.id }
);
break;
}

case 'changeChalkStatus': {
const oldChalkStatus = peer.chalkStatus;
peer.chalkStatus = data.chalkStatus || data.status || 'off';
accept();
this.broadcast(
'peerChalkStatusChanged',
{ peerId: peer.id, chalkStatus: peer.chalkStatus, oldChalkStatus },
{ excludePeerId: peer.id }
);
break;
}

case 'changeRelationName': {
const oldRelationName = peer.relationName;
peer.relationName = data.relationName || '';
accept();
this.broadcast(
'peerRelationNameChanged',
{ peerId: peer.id, relationName: peer.relationName, oldRelationName },
{ excludePeerId: peer.id }
);
break;
}

case 'changeIsShareWebcam': {
const oldIsShareWebcam = peer.isShareWebcam;
peer.isShareWebcam = Boolean(data.isShareWebcam);
accept();
this.broadcast(
'peerIsShareWebcamChanged',
{
peerId: peer.id,
isShareWebcam: peer.isShareWebcam,
oldIsShareWebcam
},
{ excludePeerId: peer.id }
);
break;
}

case 'muteRemoteMic':
case 'unmuteRemoteMic': {
accept();
const targetPeer = this.peers.get(String(data.userid));
if (targetPeer) targetPeer.notify(request.method, { peerId: peer.id });
break;
}

case 'muteAllMic':
case 'unmuteAllMic': {
accept();
this.broadcast(request.method, { peerId: peer.id }, { excludePeerId: peer.id });
break;
}

case 'applyNetworkThrottle':
case 'resetNetworkThrottle': {
// Kept as a no-op so the existing debug UI does not break.
accept();
break;
}

default: {
if (FORWARDED_COMMANDS.has(request.method)) {
accept();
this.forwardCommand({ method: request.method, data, sourcePeer: peer });
} else {
reject(404, `unknown request method "${request.method}"`);
}
}
}
}

async createWebRtcTransport({ peer, producing, consuming, forceTcp, enableSctp }) {
const transportOptions = {
...config.mediasoup.webRtcTransport,
enableSctp,
appData: {
peerId: peer.id,
producing,
consuming
}
};

if (forceTcp) {
transportOptions.enableUdp = false;
transportOptions.enableTcp = true;
transportOptions.preferTcp = true;
transportOptions.preferUdp = false;
}

const transport = await this.router.createWebRtcTransport(transportOptions);

if (
transportOptions.initialAvailableOutgoingBitrate &&
typeof transport.setMaxIncomingBitrate === 'function'
) {
await transport.setMaxIncomingBitrate(
transportOptions.initialAvailableOutgoingBitrate
);
}

peer.transports.set(transport.id, transport);

transport.on('dtlsstatechange', (dtlsState) => {
if (dtlsState === 'closed') transport.close();
});

transport.on('close', () => {
peer.transports.delete(transport.id);
});

return {
id: transport.id,
iceParameters: transport.iceParameters,
iceCandidates: transport.iceCandidates,
dtlsParameters: transport.dtlsParameters,
sctpParameters: transport.sctpParameters
};
}

async createProducer({ peer, data }) {
const transport = peer.getTransport(data.transportId);

const producer = await transport.produce({
kind: data.kind,
rtpParameters: data.rtpParameters,
appData: {
...(data.appData || {}),
peerId: peer.id
}
});

peer.producers.set(producer.id, producer);

producer.on('transportclose', () => {
peer.producers.delete(producer.id);
});

producer.on('score', (score) => {
peer.notify('producerScore', { producerId: producer.id, score });
});

if (producer.kind === 'audio' && this.activeSpeakerObserver) {
await this.activeSpeakerObserver.addProducer({ producerId: producer.id });
}

return producer;
}

async createDataProducer({ peer, data }) {
const transport = peer.getTransport(data.transportId);

const dataProducer = await transport.produceData({
sctpStreamParameters: data.sctpStreamParameters,
label: data.label,
protocol: data.protocol,
appData: {
...(data.appData || {}),
peerId: peer.id
}
});

peer.dataProducers.set(dataProducer.id, dataProducer);

dataProducer.on('transportclose', () => {
peer.dataProducers.delete(dataProducer.id);
});

return dataProducer;
}

async createExistingConsumersForPeer(peer) {
for (const producerPeer of this.getJoinedPeers(peer.id)) {
for (const producer of producerPeer.producers.values()) {
await this.safeCreateConsumer({
consumerPeer: peer,
producerPeer,
producer
});
}

for (const dataProducer of producerPeer.dataProducers.values()) {
await this.safeCreateDataConsumer({
consumerPeer: peer,
dataProducerPeer: producerPeer,
dataProducer
});
}
}
}

async createConsumersForProducer(producerPeer, producer) {
for (const consumerPeer of this.getJoinedPeers(producerPeer.id)) {
await this.safeCreateConsumer({ consumerPeer, producerPeer, producer });
}
}

async createDataConsumersForDataProducer(dataProducerPeer, dataProducer) {
for (const consumerPeer of this.getJoinedPeers(dataProducerPeer.id)) {
await this.safeCreateDataConsumer({
consumerPeer,
dataProducerPeer,
dataProducer
});
}
}

async safeCreateConsumer({ consumerPeer, producerPeer, producer }) {
try {
await this.createConsumer({ consumerPeer, producerPeer, producer });
} catch (error) {
logger.warn('createConsumer skipped', {
roomId: this.id,
consumerPeerId: consumerPeer.id,
producerPeerId: producerPeer.id,
producerId: producer.id,
error: error.message
});
}
}

async createConsumer({ consumerPeer, producerPeer, producer }) {
if (!consumerPeer.rtpCapabilities) return;

const transport = consumerPeer.getRecvTransport();
if (!transport) return;

if (
!this.router.canConsume({
producerId: producer.id,
rtpCapabilities: consumerPeer.rtpCapabilities
})
) {
return;
}

const consumer = await transport.consume({
producerId: producer.id,
rtpCapabilities: consumerPeer.rtpCapabilities,
paused: true,
appData: {
peerId: producerPeer.id
}
});

consumerPeer.consumers.set(consumer.id, consumer);

consumer.on('transportclose', () => {
consumerPeer.consumers.delete(consumer.id);
});

consumer.on('producerclose', () => {
consumerPeer.consumers.delete(consumer.id);
consumerPeer.notify('consumerClosed', { consumerId: consumer.id });
});

consumer.on('producerpause', () => {
consumerPeer.notify('consumerPaused', { consumerId: consumer.id });
});

consumer.on('producerresume', () => {
consumerPeer.notify('consumerResumed', { consumerId: consumer.id });
});

consumer.on('score', (score) => {
consumerPeer.notify('consumerScore', { consumerId: consumer.id, score });
});

consumer.on('layerschange', (layers) => {
consumerPeer.notify('consumerLayersChanged', {
consumerId: consumer.id,
spatialLayer: layers ? layers.spatialLayer : null,
temporalLayer: layers ? layers.temporalLayer : null
});
});

try {
await consumerPeer.request('newConsumer', {
peerId: producerPeer.id,
peer: producerPeer.getInfo(),
producerId: producer.id,
id: consumer.id,
kind: consumer.kind,
rtpParameters: consumer.rtpParameters,
type: consumer.type,
appData: producer.appData,
producerPaused: consumer.producerPaused
});

await consumer.resume();
if (consumer.kind === 'video') await consumer.requestKeyFrame();
} catch (error) {
consumer.close();
consumerPeer.consumers.delete(consumer.id);
throw error;
}
}

async safeCreateDataConsumer({ consumerPeer, dataProducerPeer, dataProducer }) {
try {
await this.createDataConsumer({
consumerPeer,
dataProducerPeer,
dataProducer
});
} catch (error) {
logger.warn('createDataConsumer skipped', {
roomId: this.id,
consumerPeerId: consumerPeer.id,
dataProducerPeerId: dataProducerPeer.id,
dataProducerId: dataProducer.id,
error: error.message
});
}
}

async createDataConsumer({ consumerPeer, dataProducerPeer, dataProducer }) {
const transport = consumerPeer.getRecvTransport();
if (!transport || !consumerPeer.sctpCapabilities) return;

const dataConsumer = await transport.consumeData({
dataProducerId: dataProducer.id
});

consumerPeer.dataConsumers.set(dataConsumer.id, dataConsumer);

dataConsumer.on('transportclose', () => {
consumerPeer.dataConsumers.delete(dataConsumer.id);
});

dataConsumer.on('dataproducerclose', () => {
consumerPeer.dataConsumers.delete(dataConsumer.id);
consumerPeer.notify('dataConsumerClosed', {
dataConsumerId: dataConsumer.id
});
});

try {
await consumerPeer.request('newDataConsumer', {
peerId: dataProducerPeer.id,
peer: dataProducerPeer.getInfo(),
dataProducerId: dataProducer.id,
id: dataConsumer.id,
sctpStreamParameters: dataConsumer.sctpStreamParameters,
label: dataConsumer.label,
protocol: dataConsumer.protocol,
appData: dataProducer.appData
});
} catch (error) {
dataConsumer.close();
consumerPeer.dataConsumers.delete(dataConsumer.id);
throw error;
}
}

forwardCommand({ method, data, sourcePeer }) {
const payload = {
...data,
peerId: sourcePeer.id
};

if (data.target && data.target !== 'all') {
const targetPeer = this.peers.get(String(data.target));
if (targetPeer) targetPeer.notify(method, payload);
return;
}

this.broadcast(method, payload, { excludePeerId: sourcePeer.id });
}

getJoinedPeers(excludePeerId) {
return Array.from(this.peers.values()).filter(
(peer) => peer.joined && peer.id !== excludePeerId
);
}

broadcast(method, data = {}, options = {}) {
for (const peer of this.peers.values()) {
if (!peer.joined) continue;
if (options.excludePeerId && peer.id === options.excludePeerId) continue;

peer.notify(method, data);
}
}

closePeer(peer) {
if (!this.peers.has(peer.id)) return;

logger.info('peer closed', { roomId: this.id, peerId: peer.id });
this.peers.delete(peer.id);
peer.close();
this.broadcast('peerClosed', { peerId: peer.id });

if (this.peers.size === 0) {
this.scheduleClose();
}
}

scheduleClose() {
this.clearIdleTimer();
this.idleTimer = setTimeout(() => this.close(), config.room.idleTimeoutMs);
}

clearIdleTimer() {
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = null;
}

close() {
if (this.closed) return;

this.closed = true;
this.clearIdleTimer();

for (const peer of this.peers.values()) {
peer.close();
}

this.peers.clear();

if (this.activeSpeakerObserver) this.activeSpeakerObserver.close();
this.router.close();

if (this.onEmpty) this.onEmpty(this);
logger.info('room closed', { roomId: this.id });
}
}

module.exports = Room;

+ 148
- 0
src/server.js View File

@@ -0,0 +1,148 @@
const http = require('node:http');
const express = require('express');
const protoo = require('protoo-server');
const config = require('./config');
const logger = require('./logger');
const { authenticateConnection } = require('./auth');
const MediasoupManager = require('./mediasoupManager');
const Room = require('./room');

const app = express();
const server = http.createServer(app);
const mediasoupManager = new MediasoupManager();
const rooms = new Map();
const pendingRooms = new Map();

app.disable('x-powered-by');

app.get('/healthz', (req, res) => {
res.json({
ok: true,
env: config.env,
rooms: rooms.size,
publicBaseUrl: config.publicBaseUrl || null
});
});

app.get('/rooms', (req, res) => {
res.json({
rooms: Array.from(rooms.values()).map((room) => ({
id: room.id,
peers: room.peers.size,
closed: room.closed
}))
});
});

async function getOrCreateRoom(roomId) {
const room = rooms.get(roomId);
if (room && !room.closed) return room;

const pendingRoom = pendingRooms.get(roomId);
if (pendingRoom) return pendingRoom;

const createPromise = (async () => {
const router = await mediasoupManager.createRouter({ roomId });
const nextRoom = new Room({
id: roomId,
router,
onEmpty: (emptyRoom) => {
rooms.delete(emptyRoom.id);
}
});

await nextRoom.init();
rooms.set(roomId, nextRoom);
pendingRooms.delete(roomId);

logger.info('room created', { roomId });
return nextRoom;
})().catch((error) => {
pendingRooms.delete(roomId);
throw error;
});

pendingRooms.set(roomId, createPromise);
return createPromise;
}

async function handleConnectionRequest(info, accept, reject) {
const requestUrl = new URL(info.request.url, 'http://localhost');
const roomId = requestUrl.searchParams.get('roomId');
const peerId = requestUrl.searchParams.get('peerId');

const auth = await authenticateConnection({ roomId, peerId, request: info.request });

if (!auth.ok) {
reject(auth.statusCode || 403, auth.reason || 'forbidden');
return;
}

const room = await getOrCreateRoom(roomId);
const transport = accept();

logger.info('peer connected', {
roomId,
peerId,
path: requestUrl.pathname
});

room.handleConnection({ peerId, transport });
}

async function main() {
await mediasoupManager.start();

const webSocketServer = new protoo.WebSocketServer(server, {
maxReceivedFrameSize: 960000,
maxReceivedMessageSize: 960000,
fragmentOutgoingMessages: true,
fragmentationThreshold: 960000
});

webSocketServer.on('connectionrequest', (info, accept, reject) => {
handleConnectionRequest(info, accept, reject).catch((error) => {
logger.error('connection request failed', error.stack || error.message);
reject(500, error.message);
});
});

server.listen(config.port, config.listenIp, () => {
logger.info('yzx oa sfu listening', {
listenIp: config.listenIp,
port: config.port,
publicBaseUrl: config.publicBaseUrl || null,
authEnabled: config.auth.enabled
});
});
}

async function shutdown(signal) {
logger.info('shutdown requested', { signal });

server.close(() => {
logger.info('http server closed');
});

for (const room of rooms.values()) {
room.close();
}

await mediasoupManager.close();
process.exit(0);
}

process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('unhandledRejection', (error) => {
logger.error('unhandled rejection', error.stack || error.message || error);
});
process.on('uncaughtException', (error) => {
logger.error('uncaught exception', error.stack || error.message || error);
process.exit(1);
});

main().catch((error) => {
logger.error('server startup failed', error.stack || error.message);
process.exit(1);
});

Loading…
Cancel
Save