| @@ -0,0 +1,10 @@ | |||||
| node_modules | |||||
| npm-debug.log* | |||||
| yarn-error.log* | |||||
| logs | |||||
| *.log | |||||
| *.tar | |||||
| .env | |||||
| .env.* | |||||
| .git | |||||
| .gitignore | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -0,0 +1,13 @@ | |||||
| node_modules/ | |||||
| .env | |||||
| .env.local | |||||
| npm-debug.log* | |||||
| yarn-error.log* | |||||
| logs/ | |||||
| *.log | |||||
| *.tar | |||||
| *.tar.gz | |||||
| *.tgz | |||||
| *.zip | |||||
| !deploy/.env | |||||
| @@ -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`。 | |||||
| @@ -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"] | |||||
| @@ -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 下发 | |||||
| - 多进程跨房间横向扩容 | |||||
| - 录制、旁路推流 | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| ``` | |||||
| @@ -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" | |||||
| @@ -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; | |||||
| } | |||||
| @@ -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; | |||||
| # } | |||||
| @@ -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 | |||||
| @@ -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" | |||||
| @@ -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" | |||||
| @@ -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": {} | |||||
| } | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| }; | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| }; | |||||
| @@ -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) | |||||
| }; | |||||
| @@ -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; | |||||
| @@ -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; | |||||
| @@ -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; | |||||
| @@ -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); | |||||
| }); | |||||