| @@ -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); | |||
| }); | |||