From 0439fb5701915ee36566f958b6ecaae8e98111a4 Mon Sep 17 00:00:00 2001 From: leiyun Date: Mon, 20 Jul 2026 15:06:46 +0800 Subject: [PATCH] Initial SFU service --- .dockerignore | 10 + .env.example | 35 + .env.live | 28 + .gitignore | 13 + DEPLOY.live.md | 174 ++++ Dockerfile | 23 + README.md | 149 +++ deploy/.env | 41 + deploy/README.md | 40 + deploy/install-from-cdn.sh | 104 ++ deploy/nginx/yzx_oa_sfu.conf | 32 + deploy/nginx/yzx_oa_sfu.multi-env.conf | 45 + deploy/systemd/yzx-oa-sfu.service | 15 + docker-compose.image.yml | 13 + docker-compose.yml | 13 + package-lock.json | 1298 ++++++++++++++++++++++++ package.json | 21 + scripts/check-env.ps1 | 21 + scripts/docker-build-save.ps1 | 25 + scripts/docker-load-run.sh | 57 ++ src/auth.js | 18 + src/config.js | 139 +++ src/logger.js | 13 + src/mediasoupManager.js | 58 ++ src/peer.js | 136 +++ src/room.js | 695 +++++++++++++ src/server.js | 148 +++ 27 files changed, 3364 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.live create mode 100644 .gitignore create mode 100644 DEPLOY.live.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 deploy/.env create mode 100644 deploy/README.md create mode 100644 deploy/install-from-cdn.sh create mode 100644 deploy/nginx/yzx_oa_sfu.conf create mode 100644 deploy/nginx/yzx_oa_sfu.multi-env.conf create mode 100644 deploy/systemd/yzx-oa-sfu.service create mode 100644 docker-compose.image.yml create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/check-env.ps1 create mode 100644 scripts/docker-build-save.ps1 create mode 100644 scripts/docker-load-run.sh create mode 100644 src/auth.js create mode 100644 src/config.js create mode 100644 src/logger.js create mode 100644 src/mediasoupManager.js create mode 100644 src/peer.js create mode 100644 src/room.js create mode 100644 src/server.js 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); +});