commit 0439fb5701915ee36566f958b6ecaae8e98111a4 Author: leiyun Date: Mon Jul 20 15:06:46 2026 +0800 Initial SFU service diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..013d486 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +npm-debug.log* +yarn-error.log* +logs +*.log +*.tar +.env +.env.* +.git +.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..63b932e --- /dev/null +++ b/.env.example @@ -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 diff --git a/.env.live b/.env.live new file mode 100644 index 0000000..a5a133e --- /dev/null +++ b/.env.live @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bbe3ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +.env +.env.local +npm-debug.log* +yarn-error.log* +logs/ +*.log +*.tar +*.tar.gz +*.tgz +*.zip + +!deploy/.env diff --git a/DEPLOY.live.md b/DEPLOY.live.md new file mode 100644 index 0000000..894d86b --- /dev/null +++ b/DEPLOY.live.md @@ -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`。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..66ef9d3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f599928 --- /dev/null +++ b/README.md @@ -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 下发 +- 多进程跨房间横向扩容 +- 录制、旁路推流 diff --git a/deploy/.env b/deploy/.env new file mode 100644 index 0000000..a869c55 --- /dev/null +++ b/deploy/.env @@ -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 diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..9b1c82e --- /dev/null +++ b/deploy/README.md @@ -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 +``` diff --git a/deploy/install-from-cdn.sh b/deploy/install-from-cdn.sh new file mode 100644 index 0000000..d9e6cfb --- /dev/null +++ b/deploy/install-from-cdn.sh @@ -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" diff --git a/deploy/nginx/yzx_oa_sfu.conf b/deploy/nginx/yzx_oa_sfu.conf new file mode 100644 index 0000000..cdc0e1f --- /dev/null +++ b/deploy/nginx/yzx_oa_sfu.conf @@ -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; +} diff --git a/deploy/nginx/yzx_oa_sfu.multi-env.conf b/deploy/nginx/yzx_oa_sfu.multi-env.conf new file mode 100644 index 0000000..6e36b4b --- /dev/null +++ b/deploy/nginx/yzx_oa_sfu.multi-env.conf @@ -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; +# } diff --git a/deploy/systemd/yzx-oa-sfu.service b/deploy/systemd/yzx-oa-sfu.service new file mode 100644 index 0000000..23093c0 --- /dev/null +++ b/deploy/systemd/yzx-oa-sfu.service @@ -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 diff --git a/docker-compose.image.yml b/docker-compose.image.yml new file mode 100644 index 0000000..a31fe40 --- /dev/null +++ b/docker-compose.image.yml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..485500a --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8cd3ed0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1298 @@ +{ + "name": "yzx-oa-sfu", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yzx-oa-sfu", + "version": "0.1.0", + "dependencies": { + "dotenv": "^17.2.3", + "express": "^5.1.0", + "mediasoup": "3.21.2", + "protoo-server": "4.0.8" + }, + "devDependencies": {}, + "engines": { + "node": ">=22" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/h264-profile-level-id": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/h264-profile-level-id/-/h264-profile-level-id-2.3.3.tgz", + "integrity": "sha512-rPd5gpyWuIfEbBiHC/f9I4wdeCpAhg0aWdYgjMRdVTqhJT22C6VrWTi4hpAAgJad1Vv8BJDEvrzAQTW7CxzmJw==", + "license": "ISC", + "dependencies": { + "debug": "^4.4.3" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mediasoup" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/mediasoup": { + "version": "3.21.2", + "resolved": "https://registry.npmjs.org/mediasoup/-/mediasoup-3.21.2.tgz", + "integrity": "sha512-u+icXMt/PeW9XESVRa1j8XpxMCwdVo2U2PC4FI8OZx0H6rJsMVXUBShBVteLuF60WzbLw1oGQ7w0fiW5JM2LKQ==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.3", + "flatbuffers": "^25.9.23", + "h264-profile-level-id": "^2.3.3", + "node-fetch": "^3.3.2", + "supports-color": "^10.2.2", + "tar": "^7.5.20" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mediasoup" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/protoo-server": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/protoo-server/-/protoo-server-4.0.8.tgz", + "integrity": "sha512-RCZrtdTCEAN1MkhcI11/fbI8ry3Ee2hc3FkFxirUqiqEO3uq6iusTnehXXBM4iTl3WeZIj03RkyBdHRnOrwXHg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "websocket": "^1.0.35" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tar": { + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/websocket": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", + "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", + "license": "Apache-2.0", + "dependencies": { + "bufferutil": "^4.0.1", + "debug": "^2.2.0", + "es5-ext": "^0.10.63", + "typedarray-to-buffer": "^3.1.5", + "utf-8-validate": "^5.0.2", + "yaeti": "^0.0.6" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/websocket/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/websocket/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yaeti": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", + "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "engines": { + "node": ">=0.10.32" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..62d536c --- /dev/null +++ b/package.json @@ -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": {} +} diff --git a/scripts/check-env.ps1 b/scripts/check-env.ps1 new file mode 100644 index 0000000..0de7e0f --- /dev/null +++ b/scripts/check-env.ps1 @@ -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 diff --git a/scripts/docker-build-save.ps1 b/scripts/docker-build-save.ps1 new file mode 100644 index 0000000..bfba0dc --- /dev/null +++ b/scripts/docker-build-save.ps1 @@ -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 +} diff --git a/scripts/docker-load-run.sh b/scripts/docker-load-run.sh new file mode 100644 index 0000000..749c92a --- /dev/null +++ b/scripts/docker-load-run.sh @@ -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 diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..9f51813 --- /dev/null +++ b/src/auth.js @@ -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 +}; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..3e8a135 --- /dev/null +++ b/src/config.js @@ -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 + } + } +}; diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..eacd0e2 --- /dev/null +++ b/src/logger.js @@ -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) +}; diff --git a/src/mediasoupManager.js b/src/mediasoupManager.js new file mode 100644 index 0000000..4cad951 --- /dev/null +++ b/src/mediasoupManager.js @@ -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; diff --git a/src/peer.js b/src/peer.js new file mode 100644 index 0000000..4431ffb --- /dev/null +++ b/src/peer.js @@ -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; diff --git a/src/room.js b/src/room.js new file mode 100644 index 0000000..377c0f7 --- /dev/null +++ b/src/room.js @@ -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; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..6b667fb --- /dev/null +++ b/src/server.js @@ -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); +});