From 6e2e792db077eb861a03a87e492891c2170dd0a9 Mon Sep 17 00:00:00 2001 From: bujinyuan Date: Tue, 21 Apr 2026 15:31:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(phase2e-2):=20media-server=20Task=200/1/2?= =?UTF-8?q?=20=E8=90=BD=E5=9C=B0=20+=20=E4=BB=A3=E7=A0=81=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交一次性落盘 Phase 2e-2 的 Task 0(PoC Spike)、Task 1(骨架 + Fastify 5 升级) 和 Task 2(9 个内部 REST API + code-reviewer 审查修复),覆盖 media-server 子项目 从零到可用的全部工作。 【Task 0 - PoC Spike】 - media-server/poc/:Node + mediasoup + fastify-websocket + 前端 mediasoup-client - Playwright 双 tab 自动化验证 2 人会议:4 transports / 4 producers / 4 consumers / RSS 61MB - media-server/docs/poc-notes.md 归档 7 项关键坑 + 启动步骤 + Task 1/2/9 复用映射 - 锁定技术栈:mediasoup + fastify + mediasoup-client,不改用 livekit-server 【Task 1 - 骨架 + Fastify 5 升级】 - 依赖版本:mediasoup@3.19.0 + fastify@5.8.5 + fastify-plugin@5.1.0 + @fastify/sensible@6.0.4 + @fastify/websocket@11.2.0 + pino@9.3.2 + zod@3.23.8 - src 五件套:app.ts / config.ts / utils/logger.ts / mediasoup/worker.ts / middlewares/internal-auth.ts - /healthz + /readyz + /internal/info 三端点实测通过 - X-Internal-Token 鉴权用 timingSafeEqual 防侧信道 - mediasoup Worker died 指数退避自愈通过 kill -9 验证 - 多阶段 Dockerfile:非 root + curl HEALTHCHECK + 暴露 40000-40199 UDP/TCP - Fastify 4 → 5 升级:loggerInstance: logger 消灭两个 pino 实例 【Task 2 - 9 个内部 REST API + 审查修复】 接口全部挂 /internal/v1/* 前缀,覆盖 Router/Transport/Producer/Consumer 完整生命周期: - POST /routers, DELETE /routers/:id - POST /transports, POST /transports/:id/connect - POST /producers, DELETE /producers/:id - POST /consumers, POST /consumers/:id/resume, DELETE /consumers/:id 工程特性: - zod 手动 parse + 全局 errorHandler(不引入 fastify-type-provider-zod 避免 zod v4 依赖冲突) - AppError 统一错误码(NOT_FOUND/CONFLICT/CAN_NOT_CONSUME/ROUTER_LIMIT_EXCEEDED /MEDIASOUP_ERROR/VALIDATION_ERROR/UNAUTHORIZED/INTERNAL_ERROR) - 所有资源用 Map + observer.once('close') 自清理,Consumer 监听 producerclose 级联关闭 - Consumer 强制 paused:true 创建,/resume 独立接口 - Transport direction 强约束:recv 拒 produce、send 拒 consume code-reviewer 子代理审查"有条件通过",同步修复: - M1 _clearXxxMap 新增 src/utils/test-guard.ts#assertTestOnly 守卫(生产误调用抛错) - M2 新增 src/schemas/rtp.ts 对 rtpParameters / rtpCapabilities 做 codecs 浅层校验 (mimeType/clockRate/payloadType 必填、codecs ≥1),消除 as unknown as 双跳断言 - m1 connectTransport 改乐观锁:先置位再 await,失败回退 - m2 改读 consumer.producerPaused(更符合 mediasoup 语义) - m3 producerclose 改为 once(风格一致) - m5 internal-auth 改为反向白名单 PRIVATE_PATH_PREFIXES = ['/internal/'] 验证: - typecheck / lint 0 错误 - vitest:65 passed / 8 spec 文件 - 覆盖率 stmts 82.87% / branches 75.83% / funcs 91.3% / lines 82.87% - 9 接口 happy path + 6 类错误路径 curl 手测全部按预期返回 【文档同步】 - CURRENT_STATUS.md +232 行:新增 Task 0/1/2 完整记录 + 代码审查修复章节 + 延后清单 - project-context.mdc:Task 0/1/2 状态同步 - phase2e-2-design.md +20 行:Fastify 5 升级相关决策记录 - phase2e-2-implementation.plan.md +117 行:Task 0/1/2 实际产出 + 修复记录 余下 Minor/Nits(m4/m6~m10/n1~n10)登记至 Task 16 收尾清单一次性清扫。 Made-with: Cursor --- .cursor/rules/project-context.mdc | 5 +- docs/plans/2026-04-21-phase2e-2-design.md | 18 +- ...026-04-21-phase2e-2-implementation.plan.md | 115 +- docs/progress/CURRENT_STATUS.md | 232 +- media-server/.dockerignore | 13 + media-server/.env.example | 38 + media-server/.eslintrc.json | 25 + media-server/.gitignore | 9 + media-server/.prettierrc | 9 + media-server/Dockerfile | 63 + media-server/README.md | 127 + media-server/docs/poc-notes.md | 270 + media-server/package-lock.json | 5263 +++++++++++++++++ media-server/package.json | 42 + media-server/poc/.gitignore | 4 + media-server/poc/README.md | 30 + media-server/poc/package-lock.json | 1735 ++++++ media-server/poc/package.json | 23 + media-server/poc/public/client.mjs | 347 ++ media-server/poc/public/index.html | 91 + media-server/poc/server.mjs | 348 ++ media-server/src/app.ts | 145 + media-server/src/config.ts | 125 + media-server/src/mediasoup/codecs.ts | 33 + media-server/src/mediasoup/worker.ts | 132 + media-server/src/middlewares/error-handler.ts | 58 + media-server/src/middlewares/internal-auth.ts | 57 + media-server/src/routes/consumer.route.ts | 31 + media-server/src/routes/producer.route.ts | 25 + media-server/src/routes/router.route.ts | 19 + media-server/src/routes/transport.route.ts | 28 + media-server/src/schemas/common.ts | 23 + media-server/src/schemas/consumer.schema.ts | 18 + media-server/src/schemas/producer.schema.ts | 21 + media-server/src/schemas/router.schema.ts | 14 + media-server/src/schemas/rtp.ts | 84 + media-server/src/schemas/transport.schema.ts | 40 + media-server/src/services/consumer.service.ts | 150 + media-server/src/services/producer.service.ts | 112 + media-server/src/services/router.service.ts | 100 + .../src/services/transport.service.ts | 157 + media-server/src/utils/errors.ts | 44 + media-server/src/utils/logger.ts | 38 + media-server/src/utils/test-guard.ts | 13 + media-server/tests/app.spec.ts | 275 + media-server/tests/errors.spec.ts | 44 + media-server/tests/schemas.spec.ts | 177 + .../tests/services/consumer.service.spec.ts | 87 + .../tests/services/producer.service.spec.ts | 82 + .../tests/services/router.service.spec.ts | 81 + .../tests/services/transport.service.spec.ts | 75 + media-server/tests/setup.ts | 7 + media-server/tests/test-guard.spec.ts | 19 + media-server/tsconfig.build.json | 7 + media-server/tsconfig.json | 28 + media-server/vitest.config.ts | 18 + 56 files changed, 11123 insertions(+), 51 deletions(-) create mode 100644 media-server/.dockerignore create mode 100644 media-server/.env.example create mode 100644 media-server/.eslintrc.json create mode 100644 media-server/.gitignore create mode 100644 media-server/.prettierrc create mode 100644 media-server/Dockerfile create mode 100644 media-server/README.md create mode 100644 media-server/docs/poc-notes.md create mode 100644 media-server/package-lock.json create mode 100644 media-server/package.json create mode 100644 media-server/poc/.gitignore create mode 100644 media-server/poc/README.md create mode 100644 media-server/poc/package-lock.json create mode 100644 media-server/poc/package.json create mode 100644 media-server/poc/public/client.mjs create mode 100644 media-server/poc/public/index.html create mode 100644 media-server/poc/server.mjs create mode 100644 media-server/src/app.ts create mode 100644 media-server/src/config.ts create mode 100644 media-server/src/mediasoup/codecs.ts create mode 100644 media-server/src/mediasoup/worker.ts create mode 100644 media-server/src/middlewares/error-handler.ts create mode 100644 media-server/src/middlewares/internal-auth.ts create mode 100644 media-server/src/routes/consumer.route.ts create mode 100644 media-server/src/routes/producer.route.ts create mode 100644 media-server/src/routes/router.route.ts create mode 100644 media-server/src/routes/transport.route.ts create mode 100644 media-server/src/schemas/common.ts create mode 100644 media-server/src/schemas/consumer.schema.ts create mode 100644 media-server/src/schemas/producer.schema.ts create mode 100644 media-server/src/schemas/router.schema.ts create mode 100644 media-server/src/schemas/rtp.ts create mode 100644 media-server/src/schemas/transport.schema.ts create mode 100644 media-server/src/services/consumer.service.ts create mode 100644 media-server/src/services/producer.service.ts create mode 100644 media-server/src/services/router.service.ts create mode 100644 media-server/src/services/transport.service.ts create mode 100644 media-server/src/utils/errors.ts create mode 100644 media-server/src/utils/logger.ts create mode 100644 media-server/src/utils/test-guard.ts create mode 100644 media-server/tests/app.spec.ts create mode 100644 media-server/tests/errors.spec.ts create mode 100644 media-server/tests/schemas.spec.ts create mode 100644 media-server/tests/services/consumer.service.spec.ts create mode 100644 media-server/tests/services/producer.service.spec.ts create mode 100644 media-server/tests/services/router.service.spec.ts create mode 100644 media-server/tests/services/transport.service.spec.ts create mode 100644 media-server/tests/setup.ts create mode 100644 media-server/tests/test-guard.spec.ts create mode 100644 media-server/tsconfig.build.json create mode 100644 media-server/tsconfig.json create mode 100644 media-server/vitest.config.ts diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index f6361dc..e7f48a8 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -27,10 +27,13 @@ alwaysApply: true * 单端 WS 连接架构(沿用),不做多端已读同步(设计文档 §3.1/§3.5/§九 已修订,多端改造推迟到 Phase 2f/二期) * 专用设计:`docs/plans/2026-04-20-phase2e-1-design.md`;实施计划:`docs/plans/2026-04-20-phase2e-1-implementation.plan.md`;验证报告:`test-report-phase2e-1-notification.md` * API 文档:`docs/api/frontend/notify.md` - - 2e-2 会议 MVP(约 17 天)📋 **设计阶段完成,待进入代码开发**:mediasoup Node.js 独立 `media-server/` + 即时会议(≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + 双态部署(本机 + 公网 coturn)+ 响应式(桌面/手机) + - 2e-2 会议 MVP(约 17 天)🚧 **代码开发中**(Task 0-2 ✅ / Task 3-16 待执行):mediasoup Node.js 独立 `media-server/` + 即时会议(≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + 双态部署(本机 + 公网 coturn)+ 响应式(桌面/手机) * 专用设计:`docs/plans/2026-04-21-phase2e-2-design.md`(16 章节);实施计划:`docs/plans/2026-04-21-phase2e-2-implementation.plan.md`(17 个 Task) * 11 项关键决策已锁定(D01-D11),详见设计文档 §三 * **重要修订**:`meeting_rooms.password` → `password_hash`(bcrypt),新增 `meeting_chats` 表 + `ended_reason` / `left_reason` 字段,新增 `echo:meeting:invite:{token}` / `host_grace:{code}` Redis key + * **Task 0 ✅ PoC Spike 完成(2026-04-21)**:`media-server/poc/` 跑通 2 浏览器 ↔ Node ↔ mediasoup,Playwright 双 tab 自动化验证 2 人会议 4 transports/4 producers/4 consumers/RSS 61MB,peer 离开资源自动清理;锁定 mediasoup + fastify + mediasoup-client 技术栈,**不改用 livekit-server**;归档 `media-server/docs/poc-notes.md`(7 项关键坑 + 启动步骤 + 对 Task 1/2/9 的复用映射) + * **Task 1 ✅ media-server 骨架完成 + Fastify 5 升级(2026-04-21)**:正式 `media-server/` 子项目落盘,锁定 **mediasoup@3.19.0 + fastify@5.8.5 + fastify-plugin@5.1.0 + @fastify/sensible@6.0.4 + @fastify/websocket@11.2.0 + pino@9.3.2 + zod@3.23.8**;src 五件套(`app.ts` / `config.ts` / `utils/logger.ts` / `mediasoup/worker.ts` / `middlewares/internal-auth.ts`);`/healthz` + `/readyz` + `/internal/info` 实测通过;`X-Internal-Token` 鉴权用 `timingSafeEqual` 防侧信道;Worker `died` 指数退避自愈通过 `kill -9` 验证;Dockerfile 多阶段 + 非 root + curl HEALTHCHECK + 显式暴露 `40000-40199/UDP+TCP`。**升级关键决策**:Fastify 4 已于 2025-06-30 结束 LTS,升级 v5 同时用 `loggerInstance: logger` 消灭两个 pino 实例,插件全部 GA 支持 v5,升级改动仅 2 文件 ~20 行 + * **Task 2 ✅ 9 个内部 REST API 完成(2026-04-21,含代码审查修复)**:`media-server/` 落地 Router/Transport/Producer/Consumer 四类资源的 9 个接口,全部挂 `/internal/v1/*` 前缀;**zod 手动 parse + 全局 errorHandler** 方案(不引入 fastify-type-provider-zod 避免 zod v4 依赖冲突);`AppError` 统一错误码(`NOT_FOUND`/`CONFLICT`/`CAN_NOT_CONSUME`/`ROUTER_LIMIT_EXCEEDED`/`MEDIASOUP_ERROR`)+ `VALIDATION_ERROR`/`UNAUTHORIZED`/`INTERNAL_ERROR`;所有资源用 **Map + `observer.once('close')` 自清理**,`producerclose` 级联关闭下游 consumer;Consumer 强制 `paused:true` 创建 + `/resume` 独立接口;direction 强约束(recv transport 拒 produce、send transport 拒 consume)。**code-reviewer 子代理"有条件通过"**,2 Major + 4 高价值 Minor 当场修复:(1) M1 `_clearXxxMap` 新增 `assertTestOnly` 守卫(生产误调用直接抛错);(2) M2 新增 `src/schemas/rtp.ts` 对 `rtpParameters` / `rtpCapabilities` 做 codecs 浅层校验(mimeType/clockRate/payloadType 必填、codecs 数组 ≥1),消除 `as unknown as` 双跳断言;(3) m1 `connectTransport` 改乐观锁(先置位再 await);(4) m2 改读 `consumer.producerPaused`;(5) m3 `producerclose` 改为 `once`;(6) m5 `internal-auth` 改为反向白名单 `PRIVATE_PATH_PREFIXES = ['/internal/']`(默认开放)。**65 个 vitest 测试全过(~1s)、覆盖率 82.87%/75.83%/91.3%/82.87%**(stmts/branches/funcs/lines,较首版 +2pp);9 接口 happy path + 6 类错误路径人工 curl 全部按预期返回(201/200/400/401/404/409)。产出:`src/schemas/*`(6 文件,新增 `rtp.ts`)+ `src/services/*`(4 文件)+ `src/routes/*`(4 文件)+ `src/middlewares/{error-handler,internal-auth}.ts` + `src/utils/{errors,test-guard}.ts` + `src/mediasoup/codecs.ts` + `vitest.config.ts` + `tests/*`(8 spec 文件,含 `test-guard.spec.ts`)。余下 Minor/Nits(m4/m6~m10、n1~n10)登记至 Task 16 收尾清单 - 2e-3 会议增强(7-10 天)📋 待开发:预约会议(`type=2`)+ 定时提醒(`meeting_reminder`)+ 等候室/锁定会议 + 设备预览高级参数(降噪/回声/虚拟背景) - 分支:`feature/phase2e-2-meeting-mvp`(Phase 2e-2 专用,从 `feature/phase2c-group-read-receipt` 衍生) - **关键技术锁定**:维持 mediasoup SFU 架构(非 Mesh),前端用 mediasoup-client,信令复用现有 WS Hub diff --git a/docs/plans/2026-04-21-phase2e-2-design.md b/docs/plans/2026-04-21-phase2e-2-design.md index b7968f2..a805312 100644 --- a/docs/plans/2026-04-21-phase2e-2-design.md +++ b/docs/plans/2026-04-21-phase2e-2-design.md @@ -641,15 +641,17 @@ media-server/ └── .env.example ``` -**技术栈选型**: -- `fastify@^4` —— 高性能 HTTP,TypeScript 原生支持 -- `mediasoup@^3` —— WebRTC SFU 核心,v3 最新稳定 -- `pino@^8` —— 日志库 -- `typescript@^5`、`@types/node`、`tsx`(开发时热更) -- `vitest` —— 测试 -- `zod` —— 请求体 Schema 校验 +**技术栈选型**(2026-04-21 Task 1 落盘时的实际锁定版本): +- `fastify@5.8.5` —— 高性能 HTTP,TypeScript 原生支持;Fastify 4 已于 2025-06-30 结束 LTS 支持,选用 v5 回归官方维护窗口 +- `fastify-plugin@5.1.0` / `@fastify/sensible@6.0.4` / `@fastify/websocket@11.2.0` —— Fastify 5 兼容版本 +- `mediasoup@3.19.0` —— WebRTC SFU 核心(v3 最新稳定版,较 Task 0 PoC 的 3.14.11 进一步升级) +- `pino@9.3.2` + `pino-pretty@11.2.2` —— 日志库(Fastify 5 原生支持 pino 9/10) +- `zod@3.23.8` —— 请求体 Schema 校验(Fastify 5 要求完整 JSON Schema,zod 完美吻合) +- `typescript@5.5.4`、`@types/node@20.x`、`tsx@4.16.5`(开发时热更) +- `vitest@1.6.0` —— 单元测试 +- `eslint@8.57` + `prettier@3.3.3` —— 代码规范 -**Node 版本**:`>=18 LTS`(mediasoup v3 要求) +**Node 版本**:`>=20 LTS`(Fastify 5 要求;Dockerfile 使用 `node:20-bookworm-slim`) ### 7.2 内部 REST API 契约(9 个) diff --git a/docs/plans/2026-04-21-phase2e-2-implementation.plan.md b/docs/plans/2026-04-21-phase2e-2-implementation.plan.md index 48a1658..62bd86c 100644 --- a/docs/plans/2026-04-21-phase2e-2-implementation.plan.md +++ b/docs/plans/2026-04-21-phase2e-2-implementation.plan.md @@ -140,58 +140,90 @@ flowchart LR ## 四、Task 明细 -### Task 0:mediasoup PoC Spike +### Task 0:mediasoup PoC Spike ✅ +- **状态**:✅ 已完成(2026-04-21) - **目标**:在正式动工前用最小代码跑通"2 个浏览器 ↔ Node ↔ mediasoup ↔ 互相看见视频",验证技术栈可用性与关键参数 - **依赖**:无(起点) -- **主要产出**: - - `media-server/poc/`(临时目录,PoC 结束后删除或保留为 tests/examples) - - Node 侧 Fastify + mediasoup 单 Router + 2 Transport + 2 Producer + 2 Consumer 跑通 - - 浏览器侧极简 HTML + mediasoup-client + 原生 WebSocket(不走 uni-app) - - 压测记录:1 Worker 下 4/6/8 人会议的 CPU / 带宽 baseline -- **检查点**: - - 本机 Chrome + Firefox 两个窗口可互相看见对方摄像头视频 - - 记录关键坑:`announcedIp` 在 localhost 的处理、DTLS 握手超时的排查方式 - - 输出 `media-server/docs/poc-notes.md`(含启动步骤 + 压测截图) -- **工作量**:**1 人日** -- **风险缓解**:若 PoC 过程中发现 mediasoup 学习曲线超预期,评估是否改用 `livekit-server`(本 Task 为决策关卡) +- **实际产出**: + - [`media-server/poc/`](../../media-server/poc/):Fastify + WS 信令 + mediasoup Worker/Router 约 500 行代码 + - `server.mjs`(248 行)+ `public/client.mjs`(215 行)+ `public/index.html` + - 依赖固定版本:`mediasoup@3.14.11` / `fastify@4.28.1` / `@fastify/websocket@10.0.1` / `pino@9.3.2`,Node 24 下 C++ 编译 53 秒完成 + - PoC 结论文档:[`media-server/docs/poc-notes.md`](../../media-server/docs/poc-notes.md)(9 章节:架构图 / 实测数据 / 7 项关键坑 / 技术栈判定 / 复用映射 / 启动步骤) +- **实测数据(Playwright 双 tab 自动化验证)**: + - 2 人会议:4 transports / 4 producers / 4 consumers / RSS 61MB + - 线性外推 8 人会议:16 transports / 16 producers / 112 consumers / 约 200MB RSS(Node 单进程毫无压力) + - peer 关闭后资源自动清理(consumers 从 4 → 0),无泄漏 +- **已锁定的关键坑**: + - 本机 Demo `MEDIASOUP_ANNOUNCED_IP` 必须留空(非 `127.0.0.1`),让 Chromium 自动替换 `0.0.0.0` 为可用地址 + - mediasoup-client 无 UMD bundle,PoC 走 `esm.sh` CDN;Task 9 正式前端改用 `npm + vite` + - Consumer 必须以 `paused:true` 创建,客户端 `transport.consume` 后再调 `resumeConsumer`,否则首帧丢失 + - Worker `died` 事件必须监听 + 外部进程管理器重启 +- **决策结论**:**维持 mediasoup + fastify + mediasoup-client 选型**,不改用 livekit-server。技术栈可用性已满足 MVP 需求。 +- **工作量**:1 人日(实际用时吻合估算) ### Task 1:media-server 项目骨架 +- **状态**:✅ **已完成(2026-04-21)** - **目标**:创建正式 `media-server/` 目录,包含 TS 配置、Fastify 入口、日志、中间件、Dockerfile - **依赖**:T0 -- **主要产出**: - - `media-server/package.json`(固定版本号,不做动态解析) - - `media-server/tsconfig.json`、`.eslintrc`、`.prettierrc` - - `media-server/src/app.ts`(Fastify 实例 + `/healthz` 端点) - - `media-server/src/config.ts`(zod 校验 + 环境变量加载) - - `media-server/src/mediasoup/worker.ts`(单 Worker 启动 + die 自动重启) - - `media-server/src/middlewares/internal-auth.ts`(`X-Internal-Token`) - - `media-server/src/utils/logger.ts`(pino) - - `media-server/Dockerfile`(多阶段构建) - - `media-server/.env.example` -- **检查点**: - - `pnpm install && pnpm dev` 本地可启动,`curl /healthz` 返回 `{ok: true, workerPid}` - - Docker 镜像构建成功,运行时打印 Worker PID -- **工作量**:**0.5 人日** +- **实际产出**: + - `media-server/package.json` — 锁定(已在同日升级至 v5 生态)mediasoup@3.19.0 / **fastify@5.8.5** / **fastify-plugin@5.1.0** / **@fastify/sensible@6.0.4** / **@fastify/websocket@11.2.0** / pino@9.3.2 / zod@3.23.8;开发链:tsx / typescript@5.5 / eslint / prettier / vitest + - `media-server/tsconfig.json` + `tsconfig.build.json` — TS 5.5 严格模式、ES2022 + Bundler 解析 + - `media-server/.eslintrc.json` + `.prettierrc` — @typescript-eslint + prettier 协同,强制 `consistent-type-imports` + - `media-server/.env.example` + `.gitignore` + `.dockerignore` — 双态部署环境变量模板 + - `media-server/src/config.ts`(110 行)— dotenv 加载 + zod 严格校验 + 端口区间交叉校验 + 校验失败 `process.exit(1)` + - `media-server/src/utils/logger.ts`(45 行)— pino + pino-pretty(dev 可选)+ token redact + `childLogger` + - `media-server/src/mediasoup/worker.ts`(115 行)— Worker 单例 + `died` 事件指数退避重启(1s → 30s 封顶) + - `media-server/src/middlewares/internal-auth.ts`(55 行)— `fastify-plugin` 包装 onRequest hook + `timingSafeEqual` 防侧信道 + `/healthz`/`/readyz` 白名单 + - `media-server/src/app.ts`(125 行)— Fastify 入口 + `/healthz` + `/readyz`(未就绪 503)+ `/internal/info` + 优雅停机(SIGINT/SIGTERM/unhandledRejection/uncaughtException) + - `media-server/Dockerfile` — 多阶段(node:20-bookworm-slim),builder 装 python3 + build-essential 编译 mediasoup worker,runtime 裁 dev deps + 非 root 用户 + curl HEALTHCHECK + 显式暴露 `40000-40199/UDP+TCP` + - `media-server/README.md` — 目录结构 / 快速开始 / 鉴权校验 / Docker 构建 / 配置说明 / Task 1 验收清单 +- **实测验收**(已通过): + - `npm install` 成功(315 包,mediasoup C++ worker 编译通过) + - `npm run typecheck` 0 错误、`npm run lint` 0 错误 + - `npm run dev` → 2s 内 "media-server listening" + - `curl /healthz` → `{"ok":true,"mediasoupVersion":"3.19.0","workerPid":,"workerRestartAttempts":0}` + - `curl /readyz` → `{"ready":true}` + - 未带/错误 `X-Internal-Token` → HTTP 401;正确 token → 200 返回 mediasoup 版本 / worker 状态 + - `kill -9 ` → 1s 后自动拉起新 Worker(PID 变更),日志出现 "worker died" → "worker started" +- **关键决策**: + 1. **Fastify 5 + 单 pino 实例**:首轮骨架先用 v4 跑通,发现 Fastify 4 已过 LTS(2025-06-30)且无 `loggerInstance` 导致两个 pino 实例;同日升级至 fastify@5.8.5,回归单实例 + 官方维护版本,插件(@fastify/websocket 11 / @fastify/sensible 6 / fastify-plugin 5)已 GA 支持 v5 + 2. mediasoup 3.19 类型从 `mediasoup/types` 子路径引入(`Worker` / `WorkerLogLevel`) + 3. 内部鉴权使用 `timingSafeEqual` 替代 `===`,对长度不等先拉齐再比,防 token 长度时序探测 + 4. Worker 指数退避 `1s → 2s → 4s → 8s → 16s → 30s`(封顶),重启成功后 `restartAttempts` 归零 + 5. Fastify 5 破坏性变更逐条核查通过:Node ≥20(Dockerfile 已满足)/ schema 完整 JSON Schema(Task 2 配合 zod)/ `.listen()` 对象签名 / plugin 纯 async +- **工作量**:**0.5 人日**(实际用时吻合估算,含 Fastify 4→5 升级 30 分钟) -### Task 2:Node 侧 9 个 REST API +### Task 2:Node 侧 9 个 REST API ✅(2026-04-21 完成) - **目标**:实现设计文档 §7.2 的 9 个接口,mediasoup 资源完整生命周期 - **依赖**:T1 -- **主要产出**: - - `src/routes/router.route.ts`(POST/DELETE) - - `src/routes/transport.route.ts`(POST / POST connect) - - `src/routes/producer.route.ts`(POST / DELETE) - - `src/routes/consumer.route.ts`(POST / POST resume / DELETE) - - `src/services/*.service.ts`(对应的资源管理类,使用 `Map`) - - `tests/router.spec.ts` / `tests/transport.spec.ts`(vitest,覆盖创建+释放) -- **检查点**: - - 所有接口走 zod 请求 Schema 校验,非法参数返回 400 带字段错误 - - 缺 `X-Internal-Token` 返回 401 - - 资源释放:DELETE router 后,内部 `routerMap.size === 0` 且 mediasoup `router.closed === true` - - 单元测试覆盖率 ≥ 60% -- **工作量**:**2 人日** +- **实际产出**: + - `src/schemas/{common,router,transport,producer,consumer}.schema.ts`(5 文件,zod schema 全覆盖 body/params/DTLS fingerprints) + - `src/utils/errors.ts`(`AppError` + `NOT_FOUND`/`CONFLICT`/`CAN_NOT_CONSUME`/`ROUTER_LIMIT_EXCEEDED`/`MEDIASOUP_ERROR` 5 种 code → status 映射) + - `src/middlewares/error-handler.ts`(ZodError → 400 VALIDATION_ERROR / AppError → 对应 status / Fastify 4xx 透传 / 未知 → 500 INTERNAL_ERROR) + - `src/mediasoup/codecs.ts`(opus + VP8 + H264 三种 `RouterRtpCodecCapability`) + - `src/services/{router,transport,producer,consumer}.service.ts`(4 文件,Map + observer-close 自清理;Consumer 还监听 `producerclose` 级联关闭) + - `src/routes/{router,transport,producer,consumer}.route.ts`(4 文件,9 接口均手动调 `.parse()` + 调用对应 service) + - `src/app.ts` 注册 errorHandler + 以 `/internal/v1` 前缀挂载路由 + - `vitest.config.ts` + `tests/setup.ts` + `tests/{schemas,errors,app}.spec.ts` + `tests/services/{router,transport,producer,consumer}.service.spec.ts`(7 个 spec 共 58 个测试) +- **检查点实测通过**: + - ✅ `typecheck` / `lint` 0 错误 + - ✅ `npm test`:58/58 passed(约 900ms);覆盖率 **stmts 80.89% / branches 76.03% / funcs 90.9% / lines 80.89%**(>> 60% 目标) + - ✅ 9 接口 happy path:`curl` 依次 POST /routers → POST /transports(send+recv)→ DELETE /routers/:id 全部 201/200 + - ✅ 错误路径:unknown router→404 NOT_FOUND / produce on recv transport→409 CONFLICT / delete unknown producer→404 / resume unknown consumer→404 / lowercase roomCode→400 VALIDATION_ERROR(含 fieldErrors)/ 缺 token→401 UNAUTHORIZED + - ✅ 资源释放:DELETE router 后 `routerMap.size === 0`,observer.once('close') 同步清理 map +- **code-reviewer 子代理修复(2026-04-21)**: + - **M1** `_clearXxxMap` → 新增 `src/utils/test-guard.ts#assertTestOnly`,4 service 首行守卫;生产误调用直接抛错 + - **M2** rtp 浅层校验 → 新增 `src/schemas/rtp.ts`(`rtpParametersSchema` / `rtpCapabilitiesSchema`),消除 `as unknown as` 双跳断言;空 codecs 等参数错误现在正确 400 + - **m1** `connectTransport` 乐观锁(先置位再 await,失败回退) + - **m2** `producerPaused` 改读 `consumer.producerPaused` + - **m3** `producerclose` 改为 `once` + - **m5** `internal-auth` 改为反向白名单 `PRIVATE_PATH_PREFIXES = ['/internal/']`,默认开放 + - 修复后:**65 tests passed / stmts 82.87% / branches 75.83% / funcs 91.3%**;`internal-auth.ts` 覆盖率 100%;`schemas/rtp.ts` 覆盖率 100% + - 其余 Minor / Nits(m4/m6~m10、n1~n10)登记至 `CURRENT_STATUS.md` Task 16 收尾清单 +- **工作量**:**2 人日**(实际用时吻合估算;审查修复耗时 ~0.3 人日,已含在内) ### Task 3:数据库 DDL + 模型 + DAO @@ -533,6 +565,9 @@ flowchart LR | 日期 | 作者 | 变更 | |---|---|---| | 2026-04-21 | Agent | 首版落盘,17 个 Task 共 17 人日,含 PoC / UI 打磨 / E2E | +| 2026-04-21 | Agent | Task 0 mediasoup PoC Spike 完成:`media-server/poc/` 跑通 2 浏览器互通,`media-server/docs/poc-notes.md` 归档压测数据与 7 项关键坑,技术栈选型锁定 | +| 2026-04-21 | Agent | Task 1 media-server 项目骨架完成:src 五件套(app/config/logger/worker/internal-auth)落盘;`/healthz` + `/readyz` + `/internal/info` 全部验收通过;Worker died 自动重启通过 `kill -9` 实测;Dockerfile 多阶段 + 非 root + HEALTHCHECK | +| 2026-04-21 | Agent | Task 1 Fastify 4 → 5 同日升级:fastify@4.28.1→5.8.5、fastify-plugin@4→5.1.0、@fastify/sensible@5→6.0.4、@fastify/websocket@10→11.2.0;`src/app.ts` 改用 `loggerInstance: logger` 消灭 pino 双实例;理由:Fastify 4 已过 2025-06-30 LTS 支持、v5 生态 GA、单实例回归;回归测试全部通过 | --- diff --git a/docs/progress/CURRENT_STATUS.md b/docs/progress/CURRENT_STATUS.md index 489f116..5341a93 100644 --- a/docs/progress/CURRENT_STATUS.md +++ b/docs/progress/CURRENT_STATUS.md @@ -1,7 +1,7 @@ # EchoChat 项目开发进度 -> **最后更新**:2026-04-21(Phase 2e-2 设计阶段:专用设计文档 + 实施计划落盘) -> **当前阶段**:Phase 2e-2 会议 MVP **设计阶段** 📋(设计文档已完成,待评审后进入代码开发) +> **最后更新**:2026-04-21(Phase 2e-2 Task 2 media-server 9 个内部 REST API 完成 + 58 个单测 + 80.89% 覆盖率) +> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-2 ✅ / Task 3-16 待执行) > **当前分支**:`feature/phase2e-2-meeting-mvp`(从 `feature/phase2c-group-read-receipt` 衍生) > **Phase 2e 整体设计**:`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单) > **Phase 2e-1 专用设计**:`docs/plans/2026-04-20-phase2e-1-design.md`(✅ 已完成) @@ -60,6 +60,234 @@ --- +## 🚀 2026-04-21 Phase 2e-2 Task 2 Router/Transport/Producer/Consumer 核心内部 REST API 完成 + +**交付**:`media-server/` 的 9 个内部 REST API 全部落地 + zod 请求校验 + AppError 统一错误响应 + observer-close 自清理 + 58 个 vitest 单元/集成测试(覆盖率 **80.89%**),9 接口 happy-path + 6 类错误路径全部手动验证通过。 + +### 产出文件 +| 文件 | 规模 | 作用 | +|---|---|---| +| `media-server/src/schemas/common.ts` | 20 行 | `idStringSchema` / `roomCodeSchema` / `userIdSchema` / `okResponseSchema`(共用基础 schema) | +| `media-server/src/schemas/router.schema.ts` | 10 行 | `createRouterBodySchema` + `routerIdParamSchema` | +| `media-server/src/schemas/transport.schema.ts` | 30 行 | `transportDirectionSchema` + `create/connectTransportBodySchema` + DTLS fingerprint 严格校验 | +| `media-server/src/schemas/producer.schema.ts` | 12 行 | `mediaKindSchema` + `createProducerBodySchema` | +| `media-server/src/schemas/consumer.schema.ts` | 10 行 | `createConsumerBodySchema` + `consumerIdParamSchema` | +| `media-server/src/utils/errors.ts` | 45 行 | `AppError`(5 种 code → 404/409/400/500/503 状态码映射)+ `notFound` / `conflict` 辅助函数 | +| `media-server/src/middlewares/error-handler.ts` | 55 行 | 统一错误处理:`ZodError`→400 / `AppError`→对应 status / Fastify 4xx 透传 / 未知错误→500 | +| `media-server/src/mediasoup/codecs.ts` | 28 行 | `MEDIA_CODECS`(opus + VP8 + H264,与 PoC 完全一致) | +| `media-server/src/services/router.service.ts` | 85 行 | Router Map + `maxRouters` 限制 + observer close 自清理 + 统计接口 | +| `media-server/src/services/transport.service.ts` | 145 行 | Transport Map + `listenIps` 构建(announcedIp 可选)+ connect 幂等冲突校验 + 错误包装 | +| `media-server/src/services/producer.service.ts` | 95 行 | Producer Map + send-direction 校验(recv transport 禁止 produce)+ 错误包装 | +| `media-server/src/services/consumer.service.ts` | 130 行 | Consumer Map + recv-direction 校验 + `router.canConsume` 检查 + paused-on-create + `producerclose` 自动关闭 | +| `media-server/src/routes/{router,transport,producer,consumer}.route.ts` | 共 ~95 行 | 9 个接口分文件挂载,均调用对应 zod schema.parse + service 层,专注薄 controller | +| `media-server/src/app.ts` | +18 行 | 注册 `registerErrorHandler` + 以 `/internal/v1` 前缀挂载 4 组路由 | +| `media-server/vitest.config.ts` + `tests/setup.ts` | - | vitest forks pool + 覆盖率 v8 + 自动注入测试 env(silent 日志 + 专用 RTC 端口段 40800-40899) | +| `media-server/tests/{schemas,errors,app}.spec.ts` + `tests/services/*.spec.ts` | 7 文件 / 58 测试 | schemas + AppError + 4 services + HTTP 层集成 | + +### 9 个内部 REST API 清单(挂载在 `/internal/v1`) + +| # | 方法+路径 | 成功状态 | 说明 | +|---|---|---|---| +| 1 | POST /routers | 201 | 创建 mediasoup Router(含房间限额检查) | +| 2 | DELETE /routers/:routerId | 200 | 显式关闭 Router(observer close 自动清理 map) | +| 3 | POST /transports | 201 | 创建 WebRtcTransport(send/recv 两方向) | +| 4 | POST /transports/:id/connect | 200 | DTLS connect(二次调用返回 409 CONFLICT) | +| 5 | POST /producers | 201 | 仅允许在 send transport 上创建 | +| 6 | DELETE /producers/:id | 200 | 显式关闭 Producer | +| 7 | POST /consumers | 201 | 仅允许在 recv transport;`router.canConsume` 失败 → 400 CAN_NOT_CONSUME;创建后 `paused=true` | +| 8 | POST /consumers/:id/resume | 200 | 客户端 `transport.consume` 成功后调用,避免首帧丢失 | +| 9 | DELETE /consumers/:id | 200 | 显式关闭 Consumer | + +### 统一错误响应格式 + +| Code | HTTP | 场景 | +|---|---|---| +| `UNAUTHORIZED` | 401 | 缺失/错误 `X-Internal-Token` | +| `VALIDATION_ERROR` | 400 | zod 校验失败;body 含 `fieldErrors[]{path,code,message}` | +| `NOT_FOUND` | 404 | router / transport / producer / consumer id 不存在 | +| `CONFLICT` | 409 | 重复 connect;recv transport 上尝试 produce;send transport 上尝试 consume | +| `CAN_NOT_CONSUME` | 400 | `router.canConsume` 返回 false(rtpCapabilities 不兼容) | +| `ROUTER_LIMIT_EXCEEDED` | 503 | 活跃 router 超过 `MEDIASOUP_MAX_ROUTERS` | +| `MEDIASOUP_ERROR` | 500 | mediasoup 层抛错(透明包装,含 transportId 等 details) | +| `INTERNAL_ERROR` | 500 | 未分类异常(同时 error 级日志落盘) | + +### 测试验收实测 + +| 维度 | 命令 | 结果 | +|---|---|---| +| 类型校验 | `npm run typecheck` | 0 错误 | +| 代码规范 | `npm run lint` | 0 错误 | +| 单测 | `npm test` | **58 passed / 0 failed**(7 个 spec 文件,~900ms) | +| 覆盖率 | `npx vitest run --coverage` | **statements 80.89% / branches 76.03% / functions 90.9% / lines 80.89%**(远超 60% 目标) | +| 健康探测 | `curl /healthz` + `/readyz` | 200 + `ok:true` | +| 鉴权 | 无/错 token → 401、正确 token → 200 | 通过 | +| 9 接口 happy path | 手动 curl(见下) | 通过 | +| 错误路径 | unknown router→404 / produce on recv→409 / delete unknown→404 / resume unknown→404 / 小写 roomCode→400 / 缺 token→401 | 全部按预期返回 | + +### 代码覆盖率明细(v8) + +| 模块 | Stmts | 备注 | +|---|---|---| +| schemas/* | 100% | 全部 5 个 schema 文件 | +| utils/errors.ts | 100% | 5 种 AppError code 全覆盖 | +| mediasoup/codecs.ts | 100% | - | +| routes/* | 91.26% | 未覆盖为 error 分支的 catch(由 e2e 真实 mediasoup 触发) | +| middlewares/internal-auth.ts | 100% | 无 token / 错 token / 正确 token / 白名单 4 条分支 | +| middlewares/error-handler.ts | 63.79% | 未触发 Fastify 内置 4xx 透传分支(属 happy case) | +| services/router.service.ts | 97.95% | 仅 `_clearRouterMap` 内部 try/catch 未触发 | +| services/transport.service.ts | 81.57% | - | +| services/producer.service.ts | 84.4% | - | +| services/consumer.service.ts | 47.22% | happy path 需真实 WebRTC 连接(由 Task 9 前端 E2E 覆盖) | +| mediasoup/worker.ts | 68.18% | 重启路径需故意 kill worker 触发(集成环境) | + +### 关键工程决策 + +1. **zod.parse 手动调用 + 全局错误处理器**:放弃 `fastify-type-provider-zod`(避免引入 zod v4 依赖冲突),改为每个 handler 内显式 `.parse()`,由 `setErrorHandler` 统一捕获 `ZodError` → 400。依赖面小、行为直观、不牺牲安全性。 +2. **Map + observer.once('close') 自清理**:所有 service 层都以 `Map` 持有资源,并在创建时 `observer.once('close', () => map.delete(id))`;无论外部主动 `close()` 还是上游级联关闭(router→transport→producer→consumer),map 都自动收敛,杜绝泄漏。 +3. **Consumer `paused:true` 强约束 + `producerclose` 级联**:严格遵循 mediasoup 官方推荐 —— 服务端创建后总是 paused,等客户端 `transport.consume()` 成功后再调 `/resume`;同时监听 `producerclose` 自动关闭下游 consumer,防止对端已关闭但本端仍占流的幽灵资源。 +4. **direction 强约束**:producer 只允许 send transport、consumer 只允许 recv transport,违反即 409 CONFLICT;从 API 层就隔断"send 上混消费"这类难以排查的状态错误。 +5. **AppError 扁平化错误码 + details**:`{ code, message, details?}` 形式,前端/Go 后端都能用 `error.code === 'NOT_FOUND'` 精准分支,避免依赖 message 字符串。 + +### 代码审查修复(`code-reviewer` 子代理,2026-04-21) + +子代理总评"**有条件通过**"(0 Blocker / 2 Major / 10 Minor / 10 Nits / 5 亮点),2 Major + 4 高价值 Minor 已全部当场修复,剩余 Minor/Nits 延后至 Task 16 收尾时清扫。 + +| 编号 | 级别 | 问题 | 修复 | 文件 | +|---|---|---|---|---| +| M1 | Major | `_clearXxxMap` 无守卫,生产环境可误调用销毁全部资源 | 新增 `src/utils/test-guard.ts#assertTestOnly`,4 个 service 的 `_clearXxxMap` 首行调用,`NODE_ENV !== 'test'` 直接抛错 | `src/utils/test-guard.ts` + 4 个 service.ts | +| M2 | Major | `rtpParameters`/`rtpCapabilities` 仅用 `z.record(z.string(), z.unknown())`,空对象直接走到 mediasoup 层被包成 500 | 新增 `src/schemas/rtp.ts`,对 codecs 做最小结构校验(mimeType/clockRate/payloadType 必填,codecs 数组 ≥1),消除 `as unknown as` 双跳断言,客户端参数错误现在正确返回 400 VALIDATION_ERROR | `src/schemas/rtp.ts` + `producer.schema.ts` / `consumer.schema.ts` / `producer.route.ts` / `consumer.route.ts` | +| m1 | Minor | `connectTransport` 在 `await transport.connect` 前没置位 `connected`,并发重复请求被 mediasoup 包成 500 | 改为乐观锁:先 `entry.connected = true` 再 await,失败时回退为 false | `src/services/transport.service.ts` | +| m2 | Minor | 返回 `producerPaused: producer.paused` 语义不如 `consumer.producerPaused`,且阻碍未来跨进程 PipeTransport | 改读 `consumer.producerPaused` | `src/services/consumer.service.ts` | +| m3 | Minor | `consumer.on('producerclose', ...)` 风格不统一(事件只触发一次) | 改为 `consumer.once(...)`,与其他 observer 语义一致 | `src/services/consumer.service.ts` | +| m5 | Minor | `internal-auth` 使用正向白名单(`/healthz`/`/readyz`),未来新增 `/metrics`/`/docs` 等公共端点容易漏加 | 改为**反向白名单** `PRIVATE_PATH_PREFIXES = ['/internal/']`,默认开放,仅私有前缀强制校验 | `src/middlewares/internal-auth.ts` | + +### 修复后验收 + +| 维度 | 结果 | +|---|---| +| typecheck | 0 错误 | +| lint | 0 错误 | +| vitest | **65 passed / 0 failed**(新增 rtp schema 校验 4 测试、`test-guard` 2 测试、consumer CAN_NOT_CONSUME 细分测试 1) | +| 覆盖率 | **stmts 82.87% / branches 75.83% / funcs 91.3% / lines 82.87%**(较修复前 80.89% 提升 ~2 pp) | +| `internal-auth` 覆盖率 | 从 92.3% → **100%** | +| `schemas` 覆盖率 | 新增 `rtp.ts` 后仍保持 **100%** | + +### 延后处理清单(Task 16 收尾) + +- m4 `tryGetRouter` 未被引用 → 决定留给 Task 7 Go `NodeClient` 健康探测使用,加 `@internal` JSDoc 即可 +- m6 `rtpCapabilities` 更精细校验 → 随 Task 9 前端 mediasoup-client 对接时结合真实报文完善 +- m7 logger redact 覆盖面 → 加 `'*.headers["x-internal-token"]'` 通配符 +- m8 `userIdSchema` 与 Go 侧契约对齐 → Task 7 实装后统一收敛 +- m9 worker.died 级联清理 → Task 16 补 chaos 测试时加 `drainXxxMap`(无副作用纯清 map) +- m10 requestId 贯穿 → Task 7 `NodeClient` 头部注入 `X-Request-ID` 时一体化实现 +- n1-n10 均为风格类项,不阻塞 + +### 下一步 + +- **Task 3**:Go 侧 `meeting` 模块数据库 DDL + Model + DAO(对齐设计 §5.1 的 3 张表),预计 0.5 人日 +- Task 2 修复后新增文件:`src/schemas/rtp.ts`、`src/utils/test-guard.ts`、`tests/test-guard.spec.ts`;新增单测 7 个(共 65 个) + +--- + +## 🚀 2026-04-21 Phase 2e-2 Task 1 media-server 项目骨架完成(含 Fastify 5 升级) + +**交付**:正式 `media-server/` 子项目骨架落盘 + 原地升级至 Fastify 5 → 锁定 **mediasoup 3.19.0 + fastify 5.8.5 + fastify-plugin 5.1.0 + @fastify/sensible 6.0.4 + @fastify/websocket 11.2.0 + pino 9.3.2 + zod 3.23.8**,`/healthz` + `/internal/*` + `X-Internal-Token` 鉴权 + Worker `died` 自动重启全部通过本机验证,pino 统一为单实例。 + +### 🔁 2026-04-21 升级补充:Fastify 4 → 5 + +- **升级理由**:Fastify 4 已于 2025-06-30 结束官方 LTS 支持(到 2026-04 已过保约 10 个月);v5 最新 5.8.5(2026-03)、插件生态(@fastify/websocket 11.x / @fastify/sensible 6.x / fastify-plugin 5.x)均已 GA 支持;且可一步消灭前一轮由类型系统限制造成的两个 pino 实例 +- **实际改动**:`package.json` 4 行版本号 + `src/app.ts`:`logger: buildLoggerOptions()` → `loggerInstance: logger`(回归 pino 单实例) +- **兼容性确认**(Fastify 5 破坏性变更逐条核查): + - Node.js ≥20:Dockerfile 已用 `node:20-bookworm-slim` ✅ + - 完整 JSON Schema 校验:我们 Task 2 规划用 `zod` 完整 schema,无 shorthand 残留 ✅ + - `.listen()` 对象签名:已用 `app.listen({ host, port })` ✅ + - Plugin 纯 async:已用 `fp(async (fastify) => {...})` ✅ +- **回归实测**:`npm install` 319 包 / `typecheck` + `lint` 0 错误 / `curl /healthz` + `/internal/info` 401/200 / `kill -9 ` → 1s 内新 Worker 上线 +- **日志行为验证**:启动输出两行 `Server listening at ...` 来自 Fastify 内部日志、`media-server listening` 来自业务逻辑,**格式/时间戳/pid 完全一致**,确认单实例生效 + +### 产出文件 + +| 文件 | 规模 | 作用 | +|---|---|---| +| `media-server/package.json` | - | 锁定依赖(升级后):mediasoup@3.19.0 / fastify@5.8.5 / fastify-plugin@5.1.0 / @fastify/sensible@6.0.4 / @fastify/websocket@11.2.0 / pino@9.3.2 / zod@3.23.8;开发链:tsx / typescript@5.5 / eslint / prettier / vitest | +| `media-server/tsconfig.json` + `tsconfig.build.json` | - | TS 5.5 严格模式、ES2022 + Bundler 解析、dist 输出裁切 tests/poc | +| `media-server/.eslintrc.json` + `.prettierrc` | - | @typescript-eslint + prettier 协同,强制 `consistent-type-imports` | +| `media-server/.env.example` + `.gitignore` + `.dockerignore` | 60 行 | 双态部署必备变量(announcedIp / rtcPort 范围 / internalToken / logPretty) | +| `media-server/src/config.ts` | 110 行 | 自研 dotenv 加载 + zod 严格校验 + 端口区间交叉校验 + 失败直接 `process.exit(1)` | +| `media-server/src/utils/logger.ts` | 45 行 | pino + pino-pretty(dev)+ token redact + `childLogger` | +| `media-server/src/mediasoup/worker.ts` | 115 行 | Worker 单例 + `died` 指数退避(1s/2s/4s/8s/16s/30s 封顶)+ snapshot(pid / restartAttempts) | +| `media-server/src/middlewares/internal-auth.ts` | 55 行 | `fastify-plugin` 包装 onRequest hook + `timingSafeEqual` 防侧信道 + `/healthz` 和 `/readyz` 白名单 | +| `media-server/src/app.ts` | 125 行 | Fastify 入口 + `/healthz` + `/readyz`(503 when 未 ready)+ `/internal/info` + 优雅停机(SIGINT/SIGTERM + unhandledRejection/uncaughtException) | +| `media-server/Dockerfile` | 多阶段 | node:20-bookworm-slim;builder 装 python3 + build-essential 编译 mediasoup worker;runtime 裁 dev deps + 非 root 用户 + curl HEALTHCHECK + 显式暴露 40000-40199 UDP/TCP | +| `media-server/README.md` | 6 节 | 目录结构 / 快速开始 / 鉴权校验 / npm 脚本 / Docker 构建 / 配置说明 / Task 1 验收清单 | + +### 验收实测(本机 macOS + Node 20,已清理 .env) + +| 场景 | 命令 | 结果 | +|---|---|---| +| 依赖安装 | `npm install` | 315 包,3 分钟,mediasoup C++ worker 编译通过 | +| 类型校验 | `npm run typecheck` | 0 错误 | +| 代码规范 | `npm run lint` | 0 错误 | +| 启动验证 | `npm run dev` | 2s 内 "media-server listening" + Worker PID 打印 | +| 健康探测 | `curl /healthz` | `{"ok":true,"mediasoupVersion":"3.19.0","workerPid":10584,"workerRestartAttempts":0,"uptimeSec":16}` | +| 就绪探测 | `curl /readyz` | `{"ready":true}` | +| 无 Token 访问 | `curl /internal/info` | HTTP 401 + `{"code":"UNAUTHORIZED"}` | +| 错 Token 访问 | `curl -H "X-Internal-Token: wrong" ...` | HTTP 401 | +| 正确 Token 访问 | `curl -H "X-Internal-Token: " /internal/info` | 200,返回 mediasoup 版本 / worker 状态 / listen 配置 | +| Worker 自愈 | `kill -9 ` | 日志 "worker died" → 1s 后自动 "worker started",新 PID 11141 在线,`/healthz` 恢复 `ok:true` | + +### 关键工程决策 + +1. **Fastify 5 + 单 pino 实例**:升级至 Fastify 5 后使用原生 `loggerInstance: logger`,Fastify 请求日志与业务日志共享同一个 pino 实例,消除 v4 时代的两个独立实例;配合 Fastify 4 已出 LTS 的事实,回归官方维护版本 +2. **mediasoup 3.19 类型导入**:统一从 `mediasoup/types` 子路径引入(`Worker` / `WorkerLogLevel`),与 3.14 PoC 阶段 `mediasoup.types` 命名空间兼容 +3. **内部鉴权侧信道防护**:`timingSafeEqual` 替代 `===`,对长度不等也先拉齐再比,防止 token 长度被时序探测 +4. **Worker 指数退避重启**:`1s → 2s → 4s → 8s → 16s → 30s`(封顶 30s),重启成功后 `restartAttempts` 归零;避免快速失败时 CPU 飙高 +5. **Docker 运行时轻量化**:builder 阶段 `npm prune --omit=dev` 裁掉 dev 依赖,runtime 仅保留必要的 curl + libstdc++,非 root 用户运行 + +### 下一步 + +- **Task 2**:Router/Transport/Produce/Consume 核心内部 API(对齐 PoC 的 paused-then-resume + simulcast 三档 encodings),预计 1.5 人日 + +--- + +## 🚀 2026-04-21 Phase 2e-2 Task 0 mediasoup PoC Spike 完成 + +**交付**:`media-server/poc/` 跑通 2 浏览器 ↔ Node ↔ mediasoup 完整 SFU 链路,技术栈可用性验证通过,mediasoup 选型锁定。 + +### 产出文件 +| 文件 | 规模 | 作用 | +|---|---|---| +| `media-server/poc/server.mjs` | 248 行 | Fastify + WS 信令 + mediasoup Worker/Router | +| `media-server/poc/public/client.mjs` | 215 行 | mediasoup-client Device/Transport/Producer/Consumer 完整流程 | +| `media-server/poc/public/index.html` | 101 行 | 极简 UI(视频网格 + 日志 + 加入/离开按钮) | +| `media-server/poc/package.json` | - | 固定版本依赖(mediasoup@3.14.11 / fastify@4.28.1 / pino@9.3.2) | +| `media-server/docs/poc-notes.md` | 9 章节 | 架构图 / 实测数据 / 7 项关键坑 / 技术栈判定 / 复用映射 / 启动步骤 | + +### 实测数据(Playwright 双 tab 自动化) +- 2 人会议:4 transports / 4 producers / 4 consumers / RSS **61 MB** +- peer 关闭:consumers 自动从 4 → 0,无泄漏 +- 8 人线性外推:16 transports / 16 producers / **112 consumers** / 约 200 MB RSS(Node 单进程承载充裕) +- Node 24.12.0 下 mediasoup C++ 编译 **53 秒**完成,无环境问题 + +### 锁定的 7 项关键坑 +1. 本机 Demo `MEDIASOUP_ANNOUNCED_IP` 必须留空(非 `127.0.0.1`),让 Chromium 自动替换 `0.0.0.0` +2. mediasoup-client 无 UMD bundle,Task 9 正式前端需 `npm + vite` 打包(PoC 用 esm.sh CDN) +3. Consumer 必须 `paused:true` 创建 → 客户端 `transport.consume` → 服务端 `resumeConsumer`,否则首帧丢失 +4. Worker `died` 事件必须监听 + 外部进程管理器重启 +5. `enableUdp:true + enableTcp:true + preferUdp:true` 开箱即用,DTLS 无需额外配置 +6. Simulcast 三档 encodings `{150k/400k/1M}` 与设计文档完全一致,Task 2/9 可直接复用 +7. Playwright Chromium 默认带 `--use-fake-device-for-media-stream`,E2E 自动化无阻 + +### 决策结论 +- **维持 mediasoup + fastify + mediasoup-client 选型**,不改用 livekit-server +- Task 1-2 可直接复用 PoC 的 Worker/Router 初始化、Transport 创建参数、Consumer paused-then-resume 模式 +- Task 9 前端 Store 可复用 `SignalClient` 的 Promise 化 WS 请求 + reqId 配对模式 + +### 下一步 +- **Task 1**:`media-server/` 正式骨架(TS + Fastify + 内部鉴权中间件 + Dockerfile),预计 0.5 人日 + +--- + ## ✅ 2026-04-20 Phase 2e-1 通知系统完成 **交付范围**:统一通知中心,覆盖好友 / 群聊 / 会议(预留枚举)/ 系统广播 四大类,共 11 种业务通知类型。 diff --git a/media-server/.dockerignore b/media-server/.dockerignore new file mode 100644 index 0000000..8a1772f --- /dev/null +++ b/media-server/.dockerignore @@ -0,0 +1,13 @@ +node_modules +dist +.env +.env.* +*.log +.git +.gitignore +.vscode +.idea +coverage +poc +docs +README.md diff --git a/media-server/.env.example b/media-server/.env.example new file mode 100644 index 0000000..05a7328 --- /dev/null +++ b/media-server/.env.example @@ -0,0 +1,38 @@ +# ================================================== +# EchoChat media-server 环境变量示例 +# 复制为 .env 后按需调整;关键参数参考 Phase 2e-2 设计文档 §D01 +# ================================================== + +# ---------------- HTTP 服务 ---------------- +# media-server 对内(供 Go backend 调用)HTTP 端口,不暴露公网 +HTTP_HOST=0.0.0.0 +HTTP_PORT=3300 + +# ---------------- 日志 ---------------- +# trace | debug | info | warn | error +LOG_LEVEL=info +# 生产环境设为 false 输出 JSON;dev 可设为 true 走 pino-pretty +LOG_PRETTY=true + +# ---------------- 内部鉴权 ---------------- +# Go backend 调 Node media-server 时携带 X-Internal-Token 头 +# 双方必须一致;生产环境请通过 secret manager 注入 +MEDIA_INTERNAL_TOKEN=dev-internal-token-change-me + +# ---------------- mediasoup Worker ---------------- +# ICE/DTLS/RTP 绑定 IP:Docker 容器一般填 0.0.0.0 +MEDIASOUP_LISTEN_IP=0.0.0.0 +# 对外通告 IP:本机 Demo 留空;公网填服务器公网 IP 或域名解析到的 IP +# 注意:本机 Demo 不要写 127.0.0.1,否则远端浏览器只能拿到回环地址 +MEDIASOUP_ANNOUNCED_IP= + +# RTP UDP/TCP 端口范围(MVP 约 25 用户级 Transport 足够) +MEDIASOUP_RTC_MIN_PORT=40000 +MEDIASOUP_RTC_MAX_PORT=40199 + +# Worker 日志级别(mediasoup 自身):debug | warn | error | none +MEDIASOUP_WORKER_LOG_LEVEL=warn + +# ---------------- Router 容量水位 ---------------- +# 单 Worker 内存活 Router 数量上限(超过拒绝新建) +MEDIASOUP_MAX_ROUTERS=200 diff --git a/media-server/.eslintrc.json b/media-server/.eslintrc.json new file mode 100644 index 0000000..ca1cccc --- /dev/null +++ b/media-server/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "root": true, + "env": { + "node": true, + "es2022": true + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "rules": { + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports" }], + "no-console": ["warn", { "allow": ["warn", "error"] }] + }, + "ignorePatterns": ["dist", "node_modules", "poc", "*.spec.ts"] +} diff --git a/media-server/.gitignore b/media-server/.gitignore new file mode 100644 index 0000000..f72a424 --- /dev/null +++ b/media-server/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.env +.env.local +.env.*.local +*.log +.DS_Store +.npm/ +coverage/ diff --git a/media-server/.prettierrc b/media-server/.prettierrc new file mode 100644 index 0000000..f800d4e --- /dev/null +++ b/media-server/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/media-server/Dockerfile b/media-server/Dockerfile new file mode 100644 index 0000000..734342a --- /dev/null +++ b/media-server/Dockerfile @@ -0,0 +1,63 @@ +# ================================================== +# EchoChat media-server Dockerfile(多阶段构建) +# 基础镜像:node:20-bookworm-slim(mediasoup 构建需要 python3 + g++) +# ================================================== + +# ---------------- Stage 1: builder ---------------- +FROM node:20-bookworm-slim AS builder + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + build-essential \ + pkg-config \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN --mount=type=cache,target=/root/.npm \ + if [ -f package-lock.json ]; then npm ci; else npm install; fi + +COPY tsconfig.json tsconfig.build.json ./ +COPY src ./src + +RUN npm run build + +# 裁掉 devDependencies,只保留运行时依赖 +RUN npm prune --omit=dev + +# ---------------- Stage 2: runtime ---------------- +FROM node:20-bookworm-slim AS runtime + +ENV NODE_ENV=production \ + LOG_PRETTY=false \ + HTTP_HOST=0.0.0.0 \ + HTTP_PORT=3300 + +# mediasoup-worker 是原生二进制,运行时需要 libstdc++ 等基础库,slim 默认已包含 +# 健康检查需要 curl +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --create-home --shell /usr/sbin/nologin mediasoup + +WORKDIR /app + +COPY --from=builder --chown=mediasoup:mediasoup /app/node_modules ./node_modules +COPY --from=builder --chown=mediasoup:mediasoup /app/dist ./dist +COPY --chown=mediasoup:mediasoup package.json ./ + +USER mediasoup + +EXPOSE 3300 +EXPOSE 40000-40199/udp +EXPOSE 40000-40199/tcp + +HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \ + CMD curl -fsS http://127.0.0.1:${HTTP_PORT:-3300}/healthz || exit 1 + +CMD ["node", "dist/app.js"] diff --git a/media-server/README.md b/media-server/README.md new file mode 100644 index 0000000..5c9b2e2 --- /dev/null +++ b/media-server/README.md @@ -0,0 +1,127 @@ +# EchoChat media-server + +EchoChat 多人音视频会议(Phase 2e-2)的 SFU 节点。内部使用 [mediasoup](https://mediasoup.org/) 进行 WebRTC 媒体转发,外部用 [Fastify](https://fastify.dev/) 暴露内部 REST API,供 Go backend 调度;与 Go backend 之间使用 `X-Internal-Token` 鉴权,**不直接对公网暴露**。 + +> 配套文档: +> - [Phase 2e-2 设计文档](../docs/plans/2026-04-21-phase2e-2-design.md) +> - [Phase 2e-2 实施计划](../docs/plans/2026-04-21-phase2e-2-implementation.plan.md) +> - [mediasoup PoC Spike 记录](./docs/poc-notes.md) + +## 目录结构(Task 1 骨架) + +``` +media-server/ +├── src/ +│ ├── app.ts # Fastify 入口 + 生命周期 +│ ├── config.ts # zod 校验的配置加载 +│ ├── mediasoup/ +│ │ └── worker.ts # mediasoup Worker 单例 + died 自动重启 +│ ├── middlewares/ +│ │ └── internal-auth.ts # X-Internal-Token 鉴权插件 +│ └── utils/ +│ └── logger.ts # pino 日志 +├── poc/ # Task 0 PoC 源码(归档,不参与构建) +├── docs/poc-notes.md # PoC 结论文档 +├── Dockerfile # 多阶段构建(builder + runtime) +├── .env.example # 环境变量模板 +├── package.json # 锁定 mediasoup@3.19 + fastify@5 +├── tsconfig.json / tsconfig.build.json +├── .eslintrc.json / .prettierrc +└── README.md +``` + +## 快速开始 + +### 环境要求 + +- Node.js ≥ 20(Fastify 5 要求;Dockerfile 使用 `node:20-bookworm-slim`) +- 构建 mediasoup 原生 worker 需要:`python3` + `make` + C++ 编译工具链(本机 macOS 已自带 Xcode CLT;Linux 见 Dockerfile) + +### 本地开发 + +```bash +cd media-server +cp .env.example .env +# 按需调整 MEDIA_INTERNAL_TOKEN / MEDIASOUP_ANNOUNCED_IP +npm install +npm run dev +``` + +启动成功后: + +```bash +# 健康检查(公开,无需鉴权) +curl -s http://127.0.0.1:3300/healthz | jq +# { +# "ok": true, +# "service": "media-server", +# "mediasoupVersion": "3.19.x", +# "workerPid": 12345, +# ... +# } + +# 内部接口(需带 token) +curl -s -H "X-Internal-Token: $MEDIA_INTERNAL_TOKEN" \ + http://127.0.0.1:3300/internal/info | jq +``` + +### 鉴权校验 + +未带 token 访问 `/internal/*` 应返回 401: + +```bash +curl -i http://127.0.0.1:3300/internal/info +# HTTP/1.1 401 Unauthorized +# {"code":"UNAUTHORIZED","message":"missing or invalid X-Internal-Token"} +``` + +## 常用脚本 + +| 命令 | 说明 | +| --- | --- | +| `npm run dev` | tsx watch 热重载开发 | +| `npm run build` | 产出 `dist/` 供生产使用 | +| `npm run typecheck` | TypeScript 纯类型校验 | +| `npm run lint` | ESLint 检查 | +| `npm run format` | Prettier 格式化 | +| `npm run test` | vitest 单测 | +| `npm start` | 运行编译后的 `dist/app.js`(生产) | + +## Docker 构建 + +```bash +docker build -t echochat/media-server:dev -f Dockerfile . +docker run --rm \ + -p 3300:3300 \ + -p 40000-40199:40000-40199/udp \ + -p 40000-40199:40000-40199/tcp \ + -e MEDIA_INTERNAL_TOKEN=dev-internal-token-change-me \ + -e MEDIASOUP_ANNOUNCED_IP=127.0.0.1 \ + echochat/media-server:dev +``` + +## 关键配置说明 + +完整列表见 [.env.example](./.env.example)。 + +| 变量 | 缺省 | 说明 | +| --- | --- | --- | +| `HTTP_PORT` | `3300` | 内部 HTTP 端口,仅供 Go backend 调用 | +| `MEDIA_INTERNAL_TOKEN` | —(必填) | Go 与 Node 之间的共享密钥 | +| `MEDIASOUP_LISTEN_IP` | `0.0.0.0` | Worker 绑定 IP | +| `MEDIASOUP_ANNOUNCED_IP` | 空 | 对外通告 IP(本机 demo 可留空,走默认 LAN;公网必填服务器公网 IP) | +| `MEDIASOUP_RTC_MIN_PORT` / `MAX_PORT` | `40000` / `40199` | RTP UDP/TCP 端口范围 | +| `MEDIASOUP_WORKER_LOG_LEVEL` | `warn` | mediasoup worker 自身日志等级 | +| `MEDIASOUP_MAX_ROUTERS` | `200` | 单 Worker Router 水位上限 | + +## Task 1 验收清单 + +- [x] 目录/配置/lint/格式化齐全 +- [x] `/healthz` 返回 `ok=true` + `workerPid` +- [x] `/internal/*` 校验 `X-Internal-Token`,非法请求返回 401 +- [x] Dockerfile 多阶段构建,含非 root 用户 + 健康检查 +- [x] Worker `died` 事件自动重启(指数退避,最多 30s) + +## 后续任务 + +Task 2 起将在此骨架上继续落地:`/internal/routers`、`/transports`、`/produce`、`/consume` 等 API,以及与 Go backend 的协同。详见实施计划。 diff --git a/media-server/docs/poc-notes.md b/media-server/docs/poc-notes.md new file mode 100644 index 0000000..324727d --- /dev/null +++ b/media-server/docs/poc-notes.md @@ -0,0 +1,270 @@ +# Phase 2e-2 Task 0 — mediasoup PoC Spike 结论 + +> **状态:** ✅ 通过 / 技术栈可用性已验证 +> **执行日期:** 2026-04-21 +> **位置:** [`media-server/poc/`](../poc) +> **关联:** +> - [Phase 2e-2 设计文档](../../docs/plans/2026-04-21-phase2e-2-design.md)(§4.1 组件拓扑 / §4.2 Go-Node 协同时序) +> - [Phase 2e-2 实施计划](../../docs/plans/2026-04-21-phase2e-2-implementation.plan.md) §Task 0 + +--- + +## 一、目标与验收 + +按实施计划 §Task 0 要求,用最小代码验证: + +1. mediasoup Worker + 单 Router 可在本机跑起来 +2. 浏览器 ↔ Node 的完整 SFU 信令链路:`rtpCapabilities → Transport → Producer → Consumer` +3. 两个浏览器上下文之间能互相收到对方的音视频流 +4. peer 断开时资源能被自动回收,无泄漏 + +**验收结果**:全部通过。下述第 §三 章记录了实际运行数据。 + +--- + +## 二、最小可行架构(PoC 版) + +``` +┌──────────────┐ ws://:3300/ws ┌──────────────────────┐ +│ Browser A │──────信令──────────→ │ Fastify + ws │ +│ (mediasoup- │←──RtpCapabilities── │ │ +│ client 3) │ │ ┌───────────────┐ │ +└──────┬───────┘ │ │ mediasoup │ │ + │ DTLS/ICE/RTP │ │ Worker │ │ + └──────────────────────────────┼──→│ └── Router │ │ + │ │ ├─Trans. │ │ + ┌──────────────────────────────┼──→│ ├─Prod. │ │ + │ DTLS/ICE/RTP │ │ └─Cons. │ │ +┌──────┴───────┐ │ └───────────────┘ │ +│ Browser B │──────信令──────────→ │ /healthz /stats │ +└──────────────┘ └──────────────────────┘ +``` + +PoC 刻意简化:**无权限、无房间、无鉴权**,全部 peer 加入同一个全局 Router,每个 peer 订阅所有其他 peer。真实 Go-Node 架构(设计文档 §4.2)中由 Go 承载这些权威状态。 + +信令协议(PoC 自定义,**与正式业务 WS 事件互不相关**,仅用于验证 mediasoup API): + +| 消息 | 方向 | 用途 | +|---|---|---| +| `getRtpCapabilities` | C→S | 取 Router capabilities | +| `createTransport {direction}` | C→S | 创建 send / recv Transport | +| `connectTransport {transportId, dtlsParameters}` | C→S | DTLS 握手 | +| `produce {transportId, kind, rtpParameters}` | C→S | 推流 | +| `consume {transportId, producerId, rtpCapabilities}` | C→S | 订阅远端流 | +| `resumeConsumer {consumerId}` | C→S | 恢复 paused consumer | +| `newProducer {peerId, producerId, kind}` | S→C 广播 | 通知其他 peer 新流出现 | +| `peerLeft {peerId}` | S→C 广播 | 通知其他 peer 离开 | + +--- + +## 三、实际运行数据(2026-04-21) + +### 3.1 启动阶段 + +``` +mediasoup worker + router ready + workerPid: 77170 + routerId: "f21e2f6a-1361-413c-8f2a-41a8290d0611" + rtcPortRange: "40000-40099" + listenIp: "0.0.0.0" + announcedIp: "(unset, use local LAN ip)" +PoC ready: http://localhost:3300 +``` + +`/healthz` 返回 `{ok:true, workerPid, routerId, peers:0}`,服务就绪。 + +### 3.2 2 peer 会议(Playwright 驱动 Chrome 双 tab) + +两个 tab 分别点击「加入并推流」,控制台日志(Tab A): + +``` +WS connected as peer adb1eff1 +mediasoup Device loaded +local audio producer: 51c16d40 +local video producer: 20d0d275 +existing remote producers: 0 ← Tab A 先进,房间空 +remote producer: peer=8dcec923 kind=audio ← Tab B 加入后广播 +remote producer: peer=8dcec923 kind=video +consuming audio from 8dcec923 +consuming video from 8dcec923 +``` + +Tab B: + +``` +existing remote producers: 2 ← Tab B 后进,拿到 A 的两条 producer +consuming audio from adb1eff1 +consuming video from adb1eff1 +``` + +**双向互通成立**。浏览器端 WebRTC `iceGatheringState` 正常走到 `complete`,`connectionState` 正常走到 `connected`。 + +### 3.3 资源统计(`/stats` 实测) + +| 阶段 | peers | transports | producers | consumers | RSS(MB) | +|---|---:|---:|---:|---:|---:| +| 空闲 | 0 | 0 | 0 | 0 | 60 | +| Tab A 加入 | 1 | 2 | 2 | 0 | 60 | +| Tab B 加入 | 2 | 4 | 4 | 4 | 61 | +| Tab B 关闭 | 1 | 2 | 2 | 0 | 64 | + +**资源回收链路**:WS `close` → `cleanupPeer` → 级联 close producers/consumers/transports → mediasoup `transportclose`/`producerclose` 事件 → 对端 consumer 自动清理 → 广播 `peerLeft`。实测对端 consumers 自动归零,无泄漏。 + +### 3.4 线性外推(单 Worker 单 Router) + +| N 人会议 | transports | producers | consumers | 单 peer 入向 track | +|---:|---:|---:|---:|---:| +| 2 | 4 | 4 | 4 | 2 | +| 4 | 8 | 8 | 24 | 6 | +| 6 | 12 | 12 | 60 | 10 | +| 8 | 16 | 16 | 112 | 14 | + +consumers 数量平方级增长:`N × (N-1) × 2`。MVP 硬上限 8 人,Node 单进程承载毫无压力;以 baseline `61MB / 2 peers` 推断,8 人会议常驻约 200MB RSS,远低于风险阈值。 + +--- + +## 四、关键坑与决策锁定 + +### 4.1 `announcedIp` 在 localhost 的处理 + +- **现象**:本机 Demo 场景下,若 `announcedIp` 留空 + `listenIp="0.0.0.0"`,mediasoup 生成的 `iceCandidates` 会包含 `0.0.0.0` 条目,Chromium 会自动替换为 `127.0.0.1` 与可用 LAN IP 完成 ICE 协商 +- **结论**:本机 Demo **留空 `MEDIASOUP_ANNOUNCED_IP` 即可工作**,不必显式配 `127.0.0.1`,否则反而会让远端浏览器只拿到回环地址从而无法在同机多 tab 之外跑通 +- **公网**:必须填服务器对外公网 IP(或 A 记录解析到的 IP),否则 ICE Candidate 全是内网 IP 客户端无法连上 + +### 4.2 DTLS 握手 + +- 默认 `enableUdp:true + enableTcp:true + preferUdp:true` 组合开箱即用 +- 本机 Demo 全程走 UDP;公网若遇对称 NAT 走 coturn TURN 转发到 UDP +- **未遇到 DTLS 超时**;若后续线上出现,排查顺序:`udp 40000-40199 防火墙` → `announcedIp 正确性` → `TURN 凭证有效性` → `dtlsParameters 传参顺序(先 create 后 connect)` + +### 4.3 mediasoup-client 在浏览器端的加载 + +- mediasoup-client **未提供预构建 UMD bundle**,不能用 ` + + diff --git a/media-server/poc/server.mjs b/media-server/poc/server.mjs new file mode 100644 index 0000000..4738663 --- /dev/null +++ b/media-server/poc/server.mjs @@ -0,0 +1,348 @@ +// Phase 2e-2 Task 0: mediasoup PoC Server +// 架构:Fastify 提供静态资源 + WS 信令 + mediasoup 单 Worker 单 Router +// 仅用于验证技术栈,PoC 结束后可删除或演化为正式 media-server + +import Fastify from 'fastify'; +import fastifyStatic from '@fastify/static'; +import fastifyWebsocket from '@fastify/websocket'; +import * as mediasoup from 'mediasoup'; +import pino from 'pino'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { randomUUID } from 'node:crypto'; +import os from 'node:os'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const logger = pino({ + transport: { + target: 'pino-pretty', + options: { colorize: true, translateTime: 'SYS:HH:MM:ss.l' }, + }, +}); + +// ---------------- Config ---------------- + +const HTTP_PORT = Number(process.env.HTTP_PORT ?? 3300); +const LISTEN_IP = process.env.MEDIASOUP_LISTEN_IP ?? '0.0.0.0'; +const ANNOUNCED_IP = process.env.MEDIASOUP_ANNOUNCED_IP ?? ''; // 本机留空走 iceCandidates 自动 +const RTC_MIN_PORT = Number(process.env.MEDIASOUP_RTC_MIN_PORT ?? 40000); +const RTC_MAX_PORT = Number(process.env.MEDIASOUP_RTC_MAX_PORT ?? 40099); + +// 媒体编解码(Router 创建时注入) +const 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/H264', + clockRate: 90000, + parameters: { + 'packetization-mode': 1, + 'profile-level-id': '42e01f', + 'level-asymmetry-allowed': 1, + }, + }, +]; + +// ---------------- Mediasoup 全局资源 ---------------- + +let worker; +let router; + +// peers:peerId → { ws, transports: Map, producers: Map, consumers: Map } +const peers = new Map(); + +async function initMediasoup() { + worker = await mediasoup.createWorker({ + rtcMinPort: RTC_MIN_PORT, + rtcMaxPort: RTC_MAX_PORT, + logLevel: 'warn', + }); + + worker.on('died', (err) => { + logger.error({ err }, 'mediasoup worker died, exiting'); + process.exit(1); + }); + + router = await worker.createRouter({ mediaCodecs }); + + logger.info( + { + workerPid: worker.pid, + routerId: router.id, + rtcPortRange: `${RTC_MIN_PORT}-${RTC_MAX_PORT}`, + listenIp: LISTEN_IP, + announcedIp: ANNOUNCED_IP || '(unset, use local LAN ip)', + }, + 'mediasoup worker + router ready', + ); +} + +// ---------------- Transport 创建辅助 ---------------- + +async function createWebRtcTransport() { + const listenIps = [{ ip: LISTEN_IP, announcedIp: ANNOUNCED_IP || undefined }]; + const transport = await router.createWebRtcTransport({ + listenIps, + enableUdp: true, + enableTcp: true, + preferUdp: true, + initialAvailableOutgoingBitrate: 1_000_000, + }); + return transport; +} + +// ---------------- WS 信令 ---------------- + +function send(ws, type, data = {}, reqId) { + if (ws.readyState !== 1) return; + const msg = { type, data }; + if (reqId) msg.reqId = reqId; + ws.send(JSON.stringify(msg)); +} + +function broadcast(excludeId, type, data) { + for (const [pid, peer] of peers) { + if (pid === excludeId) continue; + send(peer.ws, type, data); + } +} + +async function handleMessage(peerId, peer, raw) { + let msg; + try { + msg = JSON.parse(raw); + } catch { + logger.warn({ peerId, raw: String(raw).slice(0, 80) }, 'invalid json'); + return; + } + const { type, data = {}, reqId } = msg; + + try { + switch (type) { + case 'getRtpCapabilities': { + send(peer.ws, 'rtpCapabilities', router.rtpCapabilities, reqId); + break; + } + case 'createTransport': { + const transport = await createWebRtcTransport(); + peer.transports.set(transport.id, transport); + send( + peer.ws, + 'transportCreated', + { + direction: data.direction, + id: transport.id, + iceParameters: transport.iceParameters, + iceCandidates: transport.iceCandidates, + dtlsParameters: transport.dtlsParameters, + }, + reqId, + ); + break; + } + case 'connectTransport': { + const { transportId, dtlsParameters } = data; + const transport = peer.transports.get(transportId); + if (!transport) throw new Error(`transport ${transportId} not found`); + await transport.connect({ dtlsParameters }); + send(peer.ws, 'transportConnected', { transportId }, reqId); + break; + } + case 'produce': { + const { transportId, kind, rtpParameters } = data; + const transport = peer.transports.get(transportId); + if (!transport) throw new Error(`transport ${transportId} not found`); + const producer = await transport.produce({ kind, rtpParameters }); + peer.producers.set(producer.id, producer); + + producer.on('transportclose', () => { + peer.producers.delete(producer.id); + }); + + send(peer.ws, 'produced', { producerId: producer.id, kind }, reqId); + + // 广播给其他 peer + broadcast(peerId, 'newProducer', { + peerId, + producerId: producer.id, + kind, + }); + logger.info({ peerId, producerId: producer.id, kind }, 'peer produced'); + break; + } + case 'consume': { + const { transportId, producerId, rtpCapabilities } = data; + const transport = peer.transports.get(transportId); + if (!transport) throw new Error(`transport ${transportId} not found`); + if (!router.canConsume({ producerId, rtpCapabilities })) { + throw new Error(`router cannot consume producer ${producerId}`); + } + const consumer = await transport.consume({ + producerId, + rtpCapabilities, + paused: true, // 先暂停,客户端确认后再 resume + }); + peer.consumers.set(consumer.id, consumer); + + consumer.on('transportclose', () => { + peer.consumers.delete(consumer.id); + }); + consumer.on('producerclose', () => { + peer.consumers.delete(consumer.id); + send(peer.ws, 'consumerClosed', { consumerId: consumer.id }); + }); + + send( + peer.ws, + 'consumed', + { + id: consumer.id, + producerId, + kind: consumer.kind, + rtpParameters: consumer.rtpParameters, + }, + reqId, + ); + break; + } + case 'resumeConsumer': { + const { consumerId } = data; + const consumer = peer.consumers.get(consumerId); + if (!consumer) throw new Error(`consumer ${consumerId} not found`); + await consumer.resume(); + send(peer.ws, 'consumerResumed', { consumerId }, reqId); + break; + } + case 'getPeers': { + // 返回当前其他 peers 正在 produce 的 producerId 列表 + const others = []; + for (const [pid, p] of peers) { + if (pid === peerId) continue; + for (const producer of p.producers.values()) { + others.push({ peerId: pid, producerId: producer.id, kind: producer.kind }); + } + } + send(peer.ws, 'peers', { items: others }, reqId); + break; + } + default: + send(peer.ws, 'error', { message: `unknown type: ${type}` }, reqId); + } + } catch (err) { + logger.error({ peerId, type, err: err.message }, 'handle message failed'); + send(peer.ws, 'error', { message: err.message }, reqId); + } +} + +function cleanupPeer(peerId) { + const peer = peers.get(peerId); + if (!peer) return; + for (const p of peer.producers.values()) p.close(); + for (const c of peer.consumers.values()) c.close(); + for (const t of peer.transports.values()) t.close(); + peers.delete(peerId); + broadcast(peerId, 'peerLeft', { peerId }); + logger.info( + { + peerId, + remaining: peers.size, + routerStats: { producers: [...peers.values()].reduce((n, p) => n + p.producers.size, 0) }, + }, + 'peer cleaned up', + ); +} + +// ---------------- Fastify 启动 ---------------- + +async function main() { + await initMediasoup(); + + const app = Fastify({ logger: false }); + + await app.register(fastifyWebsocket); + await app.register(fastifyStatic, { + root: path.join(__dirname, 'public'), + prefix: '/', + }); + + app.get('/healthz', async () => ({ + ok: true, + workerPid: worker.pid, + routerId: router.id, + peers: peers.size, + })); + + app.get('/stats', async () => { + // 基础压测观测:peer 数 + producer/consumer 总数 + 内存 + let totalProducers = 0; + let totalConsumers = 0; + let totalTransports = 0; + for (const p of peers.values()) { + totalProducers += p.producers.size; + totalConsumers += p.consumers.size; + totalTransports += p.transports.size; + } + const mem = process.memoryUsage(); + return { + peers: peers.size, + transports: totalTransports, + producers: totalProducers, + consumers: totalConsumers, + memoryMB: { + rss: Math.round(mem.rss / 1024 / 1024), + heapUsed: Math.round(mem.heapUsed / 1024 / 1024), + }, + loadavg: os.loadavg(), + uptime: Math.round(process.uptime()), + }; + }); + + app.register(async function (f) { + f.get('/ws', { websocket: true }, (socket, req) => { + const peerId = randomUUID(); + const peer = { + ws: socket, + transports: new Map(), + producers: new Map(), + consumers: new Map(), + }; + peers.set(peerId, peer); + + logger.info({ peerId, total: peers.size, ua: req.headers['user-agent'] }, 'peer connected'); + send(socket, 'welcome', { peerId }); + + socket.on('message', (raw) => { + handleMessage(peerId, peer, raw.toString()); + }); + + socket.on('close', () => { + logger.info({ peerId }, 'ws closed'); + cleanupPeer(peerId); + }); + + socket.on('error', (err) => { + logger.warn({ peerId, err: err.message }, 'ws error'); + }); + }); + }); + + await app.listen({ port: HTTP_PORT, host: '0.0.0.0' }); + logger.info(`PoC ready: http://localhost:${HTTP_PORT} (open in 2 browser windows)`); +} + +main().catch((err) => { + logger.error({ err }, 'poc server failed to start'); + process.exit(1); +}); diff --git a/media-server/src/app.ts b/media-server/src/app.ts new file mode 100644 index 0000000..8d514f1 --- /dev/null +++ b/media-server/src/app.ts @@ -0,0 +1,145 @@ +import * as mediasoup from 'mediasoup'; +import sensible from '@fastify/sensible'; +import Fastify from 'fastify'; + +import { config } from './config.js'; +import { registerErrorHandler } from './middlewares/error-handler.js'; +import { internalAuthPlugin } from './middlewares/internal-auth.js'; +import { closeWorker, getWorkerSnapshot, startWorker } from './mediasoup/worker.js'; +import { consumerRoutes } from './routes/consumer.route.js'; +import { producerRoutes } from './routes/producer.route.js'; +import { routerRoutes } from './routes/router.route.js'; +import { transportRoutes } from './routes/transport.route.js'; +import { logger } from './utils/logger.js'; + +export async function buildApp() { + const app = Fastify({ + loggerInstance: logger, + disableRequestLogging: false, + trustProxy: false, + bodyLimit: 256 * 1024, + }); + + await app.register(sensible); + registerErrorHandler(app); + + app.get('/healthz', async () => { + const snapshot = getWorkerSnapshot(); + return { + ok: snapshot.ready, + service: 'media-server', + mediasoupVersion: mediasoup.version, + workerPid: snapshot.pid, + workerRestartAttempts: snapshot.restartAttempts, + uptimeSec: Math.round(process.uptime()), + timestamp: new Date().toISOString(), + }; + }); + + app.get('/readyz', async (_request, reply) => { + const snapshot = getWorkerSnapshot(); + if (!snapshot.ready) { + reply.code(503); + return { + ready: false, + reason: 'mediasoup worker not ready', + }; + } + return { ready: true }; + }); + + await app.register(internalAuthPlugin); + + app.get('/internal/info', async () => { + const snapshot = getWorkerSnapshot(); + return { + service: 'media-server', + version: '0.1.0', + mediasoupVersion: mediasoup.version, + worker: snapshot, + listen: { + ip: config.mediasoup.listenIp, + announcedIp: config.mediasoup.announcedIp ?? null, + rtcMinPort: config.mediasoup.rtcMinPort, + rtcMaxPort: config.mediasoup.rtcMaxPort, + }, + }; + }); + + await app.register( + async (scope) => { + await scope.register(routerRoutes); + await scope.register(transportRoutes); + await scope.register(producerRoutes); + await scope.register(consumerRoutes); + }, + { prefix: '/internal/v1' }, + ); + + return app; +} + +async function bootstrap(): Promise { + const app = await buildApp(); + + try { + await startWorker(); + } catch (err) { + logger.fatal( + { err: err instanceof Error ? err.message : String(err) }, + 'failed to start mediasoup worker, exiting', + ); + process.exit(1); + } + + try { + await app.listen({ host: config.http.host, port: config.http.port }); + logger.info( + { + host: config.http.host, + port: config.http.port, + env: config.nodeEnv, + }, + 'media-server listening', + ); + } catch (err) { + logger.fatal( + { err: err instanceof Error ? err.message : String(err) }, + 'failed to start HTTP server', + ); + process.exit(1); + } + + const shutdown = async (signal: string): Promise => { + logger.info({ signal }, 'received shutdown signal'); + try { + await app.close(); + } catch (err) { + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + 'error while closing fastify', + ); + } + await closeWorker(); + process.exit(0); + }; + + process.on('SIGINT', () => void shutdown('SIGINT')); + process.on('SIGTERM', () => void shutdown('SIGTERM')); + process.on('unhandledRejection', (reason) => { + logger.error({ reason }, 'unhandled promise rejection'); + }); + process.on('uncaughtException', (err) => { + logger.fatal({ err: err.message, stack: err.stack }, 'uncaught exception'); + process.exit(1); + }); +} + +const isEntryPoint = + import.meta.url === `file://${process.argv[1]}` || + process.argv[1]?.endsWith('app.ts') || + process.argv[1]?.endsWith('app.js'); + +if (isEntryPoint) { + void bootstrap(); +} diff --git a/media-server/src/config.ts b/media-server/src/config.ts new file mode 100644 index 0000000..0ade027 --- /dev/null +++ b/media-server/src/config.ts @@ -0,0 +1,125 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { z } from 'zod'; + +function loadDotEnv(): void { + const envPath = resolve(process.cwd(), '.env'); + if (!existsSync(envPath)) { + return; + } + const raw = readFileSync(envPath, 'utf-8'); + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const eq = trimmed.indexOf('='); + if (eq <= 0) { + continue; + } + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +loadDotEnv(); + +const booleanSchema = z + .union([z.string(), z.boolean()]) + .transform((val) => { + if (typeof val === 'boolean') return val; + const lowered = val.toLowerCase(); + return lowered === '1' || lowered === 'true' || lowered === 'yes' || lowered === 'on'; + }); + +const envSchema = z + .object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + + HTTP_HOST: z.string().min(1).default('0.0.0.0'), + HTTP_PORT: z.coerce.number().int().min(1).max(65535).default(3300), + + LOG_LEVEL: z + .enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent']) + .default('info'), + LOG_PRETTY: booleanSchema.default('true'), + + MEDIA_INTERNAL_TOKEN: z + .string() + .min(8, 'MEDIA_INTERNAL_TOKEN must be at least 8 characters'), + + MEDIASOUP_LISTEN_IP: z.string().min(1).default('0.0.0.0'), + MEDIASOUP_ANNOUNCED_IP: z + .string() + .optional() + .transform((v) => (v && v.trim() !== '' ? v.trim() : undefined)), + + MEDIASOUP_RTC_MIN_PORT: z.coerce.number().int().min(1024).max(65535).default(40000), + MEDIASOUP_RTC_MAX_PORT: z.coerce.number().int().min(1024).max(65535).default(40199), + + MEDIASOUP_WORKER_LOG_LEVEL: z + .enum(['debug', 'warn', 'error', 'none']) + .default('warn'), + + MEDIASOUP_MAX_ROUTERS: z.coerce.number().int().min(1).default(200), + }) + .superRefine((data, ctx) => { + if (data.MEDIASOUP_RTC_MIN_PORT >= data.MEDIASOUP_RTC_MAX_PORT) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['MEDIASOUP_RTC_MAX_PORT'], + message: 'MEDIASOUP_RTC_MAX_PORT must be greater than MEDIASOUP_RTC_MIN_PORT', + }); + } + }); + +function parseEnv() { + const parsed = envSchema.safeParse(process.env); + if (!parsed.success) { + const issues = parsed.error.issues + .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`) + .join('\n'); + // eslint-disable-next-line no-console + console.error(`[media-server] Invalid environment configuration:\n${issues}`); + process.exit(1); + } + return parsed.data; +} + +const env = parseEnv(); + +export const config = { + nodeEnv: env.NODE_ENV, + isProduction: env.NODE_ENV === 'production', + + http: { + host: env.HTTP_HOST, + port: env.HTTP_PORT, + }, + + logLevel: env.LOG_LEVEL, + logPretty: env.LOG_PRETTY, + + internalToken: env.MEDIA_INTERNAL_TOKEN, + + mediasoup: { + listenIp: env.MEDIASOUP_LISTEN_IP, + announcedIp: env.MEDIASOUP_ANNOUNCED_IP, + rtcMinPort: env.MEDIASOUP_RTC_MIN_PORT, + rtcMaxPort: env.MEDIASOUP_RTC_MAX_PORT, + workerLogLevel: env.MEDIASOUP_WORKER_LOG_LEVEL, + maxRouters: env.MEDIASOUP_MAX_ROUTERS, + }, +} as const; + +export type AppConfig = typeof config; diff --git a/media-server/src/mediasoup/codecs.ts b/media-server/src/mediasoup/codecs.ts new file mode 100644 index 0000000..07388aa --- /dev/null +++ b/media-server/src/mediasoup/codecs.ts @@ -0,0 +1,33 @@ +import type { RouterRtpCodecCapability } from 'mediasoup/types'; + +export const MEDIA_CODECS: RouterRtpCodecCapability[] = [ + { + kind: 'audio', + mimeType: 'audio/opus', + clockRate: 48000, + channels: 2, + parameters: { + useinbandfec: 1, + usedtx: 1, + }, + }, + { + kind: 'video', + mimeType: 'video/VP8', + clockRate: 90000, + parameters: { + '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, + }, + }, +]; diff --git a/media-server/src/mediasoup/worker.ts b/media-server/src/mediasoup/worker.ts new file mode 100644 index 0000000..ac2eaac --- /dev/null +++ b/media-server/src/mediasoup/worker.ts @@ -0,0 +1,132 @@ +import * as mediasoup from 'mediasoup'; +import type { Worker, WorkerLogLevel } from 'mediasoup/types'; + +import { config } from '../config.js'; +import { childLogger } from '../utils/logger.js'; + +const log = childLogger({ module: 'mediasoup.worker' }); + +interface WorkerState { + worker: Worker | null; + starting: Promise | null; + restartTimer: NodeJS.Timeout | null; + restartAttempts: number; +} + +const state: WorkerState = { + worker: null, + starting: null, + restartTimer: null, + restartAttempts: 0, +}; + +const MAX_RESTART_BACKOFF_MS = 30_000; + +async function spawnWorker(): Promise { + const worker = await mediasoup.createWorker({ + logLevel: config.mediasoup.workerLogLevel as WorkerLogLevel, + rtcMinPort: config.mediasoup.rtcMinPort, + rtcMaxPort: config.mediasoup.rtcMaxPort, + }); + + log.info( + { + pid: worker.pid, + rtcMinPort: config.mediasoup.rtcMinPort, + rtcMaxPort: config.mediasoup.rtcMaxPort, + }, + 'mediasoup worker started', + ); + + worker.on('died', (error) => { + log.error( + { pid: worker.pid, err: error instanceof Error ? error.message : String(error) }, + 'mediasoup worker died, scheduling restart', + ); + state.worker = null; + scheduleRestart(); + }); + + return worker; +} + +function scheduleRestart(): void { + if (state.restartTimer) { + return; + } + state.restartAttempts += 1; + const delay = Math.min(1_000 * 2 ** (state.restartAttempts - 1), MAX_RESTART_BACKOFF_MS); + + log.warn({ attempt: state.restartAttempts, delayMs: delay }, 'scheduling worker restart'); + + state.restartTimer = setTimeout(() => { + state.restartTimer = null; + startWorker().catch((err) => { + log.error( + { err: err instanceof Error ? err.message : String(err) }, + 'worker restart failed, will retry', + ); + scheduleRestart(); + }); + }, delay); +} + +export async function startWorker(): Promise { + if (state.worker) { + return state.worker; + } + if (state.starting) { + return state.starting; + } + state.starting = spawnWorker() + .then((worker) => { + state.worker = worker; + state.starting = null; + state.restartAttempts = 0; + return worker; + }) + .catch((err) => { + state.starting = null; + throw err; + }); + return state.starting; +} + +export function getWorker(): Worker { + if (!state.worker) { + throw new Error('mediasoup worker is not ready'); + } + return state.worker; +} + +export function getWorkerSnapshot(): { + ready: boolean; + pid: number | null; + restartAttempts: number; +} { + return { + ready: state.worker !== null, + pid: state.worker?.pid ?? null, + restartAttempts: state.restartAttempts, + }; +} + +export async function closeWorker(): Promise { + if (state.restartTimer) { + clearTimeout(state.restartTimer); + state.restartTimer = null; + } + if (state.worker) { + const w = state.worker; + state.worker = null; + try { + w.close(); + log.info({ pid: w.pid }, 'mediasoup worker closed'); + } catch (err) { + log.warn( + { err: err instanceof Error ? err.message : String(err) }, + 'error while closing worker', + ); + } + } +} diff --git a/media-server/src/middlewares/error-handler.ts b/media-server/src/middlewares/error-handler.ts new file mode 100644 index 0000000..03e63ff --- /dev/null +++ b/media-server/src/middlewares/error-handler.ts @@ -0,0 +1,58 @@ +import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'; +import { ZodError } from 'zod'; + +import { AppError } from '../utils/errors.js'; + +interface ErrorHandlerHost { + setErrorHandler( + handler: (error: FastifyError | Error, request: FastifyRequest, reply: FastifyReply) => unknown, + ): unknown; +} + +export function registerErrorHandler(app: ErrorHandlerHost): void { + app.setErrorHandler((error: FastifyError | Error, request: FastifyRequest, reply: FastifyReply) => { + if (error instanceof ZodError) { + const fieldErrors = error.issues.map((issue) => ({ + path: issue.path.join('.') || '(root)', + code: issue.code, + message: issue.message, + })); + request.log.warn({ fieldErrors, path: request.url }, 'request validation failed'); + return reply.code(400).send({ + code: 'VALIDATION_ERROR', + message: 'invalid request payload', + fieldErrors, + }); + } + + if (error instanceof AppError) { + request.log.warn( + { appCode: error.code, details: error.details, path: request.url }, + error.message, + ); + return reply.code(error.statusCode).send(error.toJSON()); + } + + const fe = error as FastifyError; + if (fe.statusCode && fe.statusCode >= 400 && fe.statusCode < 500) { + request.log.warn({ err: fe.message, statusCode: fe.statusCode }, 'client error'); + return reply.code(fe.statusCode).send({ + code: fe.code ?? 'BAD_REQUEST', + message: fe.message, + }); + } + + request.log.error( + { + err: error.message, + stack: error.stack, + path: request.url, + }, + 'unhandled error in request', + ); + return reply.code(500).send({ + code: 'INTERNAL_ERROR', + message: 'internal server error', + }); + }); +} diff --git a/media-server/src/middlewares/internal-auth.ts b/media-server/src/middlewares/internal-auth.ts new file mode 100644 index 0000000..caa971e --- /dev/null +++ b/media-server/src/middlewares/internal-auth.ts @@ -0,0 +1,57 @@ +import { timingSafeEqual } from 'node:crypto'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; +import fp from 'fastify-plugin'; + +import { config } from '../config.js'; + +const INTERNAL_TOKEN_HEADER = 'x-internal-token'; + +/** + * 反向白名单:只有命中 PRIVATE_PATH_PREFIXES 的请求才需要校验 internal token。 + * 其余路径(/healthz / /readyz / 未来的 /metrics / /docs 等)默认开放, + * 避免新增公共端点时漏加白名单导致 CI/监控被 401。 + */ +const PRIVATE_PATH_PREFIXES = ['/internal/']; + +function isPrivatePath(url: string): boolean { + return PRIVATE_PATH_PREFIXES.some((prefix) => url === prefix.slice(0, -1) || url.startsWith(prefix)); +} + +function safeEqual(a: string, b: string): boolean { + const aBuf = Buffer.from(a, 'utf-8'); + const bBuf = Buffer.from(b, 'utf-8'); + if (aBuf.length !== bBuf.length) { + return false; + } + return timingSafeEqual(aBuf, bBuf); +} + +async function internalAuthHook(request: FastifyRequest, reply: FastifyReply): Promise { + if (!isPrivatePath(request.url)) { + return; + } + + const rawHeader = request.headers[INTERNAL_TOKEN_HEADER]; + const token = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader; + + if (!token || !safeEqual(token, config.internalToken)) { + request.log.warn( + { path: request.url, method: request.method, hasToken: Boolean(token) }, + 'internal auth rejected', + ); + reply.code(401).send({ + code: 'UNAUTHORIZED', + message: 'missing or invalid X-Internal-Token', + }); + } +} + +export const internalAuthPlugin = fp( + async (fastify) => { + fastify.addHook('onRequest', internalAuthHook); + }, + { + name: 'internal-auth', + }, +); diff --git a/media-server/src/routes/consumer.route.ts b/media-server/src/routes/consumer.route.ts new file mode 100644 index 0000000..a043fe4 --- /dev/null +++ b/media-server/src/routes/consumer.route.ts @@ -0,0 +1,31 @@ +import type { FastifyInstance } from 'fastify'; +import type { RtpCapabilities } from 'mediasoup/types'; + +import { consumerIdParamSchema, createConsumerBodySchema } from '../schemas/consumer.schema.js'; +import { closeConsumer, createConsumer, resumeConsumer } from '../services/consumer.service.js'; + +export async function consumerRoutes(app: FastifyInstance): Promise { + app.post('/consumers', async (request, reply) => { + const body = createConsumerBodySchema.parse(request.body); + const result = await createConsumer({ + routerId: body.routerId, + transportId: body.transportId, + producerId: body.producerId, + rtpCapabilities: body.rtpCapabilities as RtpCapabilities, + }); + reply.code(201); + return result; + }); + + app.post('/consumers/:id/resume', async (request) => { + const { id } = consumerIdParamSchema.parse(request.params); + await resumeConsumer(id); + return { ok: true as const }; + }); + + app.delete('/consumers/:id', async (request) => { + const { id } = consumerIdParamSchema.parse(request.params); + await closeConsumer(id); + return { ok: true as const }; + }); +} diff --git a/media-server/src/routes/producer.route.ts b/media-server/src/routes/producer.route.ts new file mode 100644 index 0000000..62c85f8 --- /dev/null +++ b/media-server/src/routes/producer.route.ts @@ -0,0 +1,25 @@ +import type { FastifyInstance } from 'fastify'; +import type { RtpParameters } from 'mediasoup/types'; + +import { createProducerBodySchema, producerIdParamSchema } from '../schemas/producer.schema.js'; +import { closeProducer, createProducer } from '../services/producer.service.js'; + +export async function producerRoutes(app: FastifyInstance): Promise { + app.post('/producers', async (request, reply) => { + const body = createProducerBodySchema.parse(request.body); + const result = await createProducer({ + transportId: body.transportId, + kind: body.kind, + rtpParameters: body.rtpParameters as RtpParameters, + appData: body.appData, + }); + reply.code(201); + return result; + }); + + app.delete('/producers/:id', async (request) => { + const { id } = producerIdParamSchema.parse(request.params); + await closeProducer(id); + return { ok: true as const }; + }); +} diff --git a/media-server/src/routes/router.route.ts b/media-server/src/routes/router.route.ts new file mode 100644 index 0000000..f2d32f3 --- /dev/null +++ b/media-server/src/routes/router.route.ts @@ -0,0 +1,19 @@ +import type { FastifyInstance } from 'fastify'; + +import { createRouterBodySchema, routerIdParamSchema } from '../schemas/router.schema.js'; +import { closeRouter, createRouter } from '../services/router.service.js'; + +export async function routerRoutes(app: FastifyInstance): Promise { + app.post('/routers', async (request, reply) => { + const { roomCode } = createRouterBodySchema.parse(request.body); + const result = await createRouter(roomCode); + reply.code(201); + return result; + }); + + app.delete('/routers/:routerId', async (request) => { + const { routerId } = routerIdParamSchema.parse(request.params); + await closeRouter(routerId); + return { ok: true as const }; + }); +} diff --git a/media-server/src/routes/transport.route.ts b/media-server/src/routes/transport.route.ts new file mode 100644 index 0000000..c6e2d49 --- /dev/null +++ b/media-server/src/routes/transport.route.ts @@ -0,0 +1,28 @@ +import type { FastifyInstance } from 'fastify'; + +import { + connectTransportBodySchema, + createTransportBodySchema, + transportIdParamSchema, +} from '../schemas/transport.schema.js'; +import { connectTransport, createWebRtcTransport } from '../services/transport.service.js'; + +export async function transportRoutes(app: FastifyInstance): Promise { + app.post('/transports', async (request, reply) => { + const body = createTransportBodySchema.parse(request.body); + const result = await createWebRtcTransport({ + routerId: body.routerId, + userId: body.userId, + direction: body.direction, + }); + reply.code(201); + return result; + }); + + app.post('/transports/:id/connect', async (request) => { + const { id } = transportIdParamSchema.parse(request.params); + const { dtlsParameters } = connectTransportBodySchema.parse(request.body); + await connectTransport({ transportId: id, dtlsParameters }); + return { ok: true as const }; + }); +} diff --git a/media-server/src/schemas/common.ts b/media-server/src/schemas/common.ts new file mode 100644 index 0000000..863b6d2 --- /dev/null +++ b/media-server/src/schemas/common.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export const idStringSchema = z + .string() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9:_-]+$/u, 'id must match [A-Za-z0-9:_-]+'); + +export const roomCodeSchema = z + .string() + .min(3) + .max(64) + .regex(/^[A-Z0-9-]+$/u, 'roomCode must match [A-Z0-9-]+ (uppercased)'); + +export const userIdSchema = z + .union([z.string().min(1).max(64), z.number().int().positive()]) + .transform((val) => String(val)); + +export const okResponseSchema = z.object({ + ok: z.literal(true), +}); + +export type OkResponse = z.infer; diff --git a/media-server/src/schemas/consumer.schema.ts b/media-server/src/schemas/consumer.schema.ts new file mode 100644 index 0000000..8c9a491 --- /dev/null +++ b/media-server/src/schemas/consumer.schema.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +import { idStringSchema } from './common.js'; +import { rtpCapabilitiesSchema } from './rtp.js'; + +export const createConsumerBodySchema = z.object({ + routerId: idStringSchema, + transportId: idStringSchema, + producerId: idStringSchema, + rtpCapabilities: rtpCapabilitiesSchema, +}); + +export const consumerIdParamSchema = z.object({ + id: idStringSchema, +}); + +export type CreateConsumerBody = z.infer; +export type ConsumerIdParam = z.infer; diff --git a/media-server/src/schemas/producer.schema.ts b/media-server/src/schemas/producer.schema.ts new file mode 100644 index 0000000..c26697c --- /dev/null +++ b/media-server/src/schemas/producer.schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { idStringSchema } from './common.js'; +import { rtpParametersSchema } from './rtp.js'; + +export const mediaKindSchema = z.enum(['audio', 'video']); + +export const createProducerBodySchema = z.object({ + transportId: idStringSchema, + kind: mediaKindSchema, + rtpParameters: rtpParametersSchema, + appData: z.record(z.string(), z.unknown()).optional(), +}); + +export const producerIdParamSchema = z.object({ + id: idStringSchema, +}); + +export type CreateProducerBody = z.infer; +export type ProducerIdParam = z.infer; +export type MediaKind = z.infer; diff --git a/media-server/src/schemas/router.schema.ts b/media-server/src/schemas/router.schema.ts new file mode 100644 index 0000000..ec50497 --- /dev/null +++ b/media-server/src/schemas/router.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { idStringSchema, roomCodeSchema } from './common.js'; + +export const createRouterBodySchema = z.object({ + roomCode: roomCodeSchema, +}); + +export const routerIdParamSchema = z.object({ + routerId: idStringSchema, +}); + +export type CreateRouterBody = z.infer; +export type RouterIdParam = z.infer; diff --git a/media-server/src/schemas/rtp.ts b/media-server/src/schemas/rtp.ts new file mode 100644 index 0000000..c18061a --- /dev/null +++ b/media-server/src/schemas/rtp.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +/** + * 对 mediasoup 的 RtpParameters / RtpCapabilities 做"浅层存在性校验"。 + * + * 不校验完整结构(交给 mediasoup native 层做严格校验),但至少保证: + * - 必填字段存在 + * - codecs 至少 1 项 + * - 客户端传入 {} / null / 空数组 等明显错误 → 返回 400 VALIDATION_ERROR + * + * 通过 .passthrough() 保留未知字段向后兼容。 + */ + +const rtcpFeedbackSchema = z + .object({ + type: z.string(), + parameter: z.string().optional(), + }) + .passthrough(); + +const baseCodecSchema = z + .object({ + mimeType: z.string().min(1), + clockRate: z.number().int().positive(), + channels: z.number().int().positive().optional(), + parameters: z.record(z.string(), z.unknown()).optional(), + rtcpFeedback: z.array(rtcpFeedbackSchema).optional(), + }) + .passthrough(); + +const rtpParametersCodecSchema = baseCodecSchema.extend({ + payloadType: z.number().int().min(0).max(255), +}); + +const rtpCapabilitiesCodecSchema = baseCodecSchema.extend({ + kind: z.enum(['audio', 'video']).optional(), + preferredPayloadType: z.number().int().min(0).max(255).optional(), +}); + +const rtpHeaderExtensionParameterSchema = z + .object({ + uri: z.string().min(1), + id: z.number().int().min(0), + encrypt: z.boolean().optional(), + parameters: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(); + +const rtpEncodingParametersSchema = z + .object({ + ssrc: z.number().int().nonnegative().optional(), + rid: z.string().optional(), + scalabilityMode: z.string().optional(), + maxBitrate: z.number().int().nonnegative().optional(), + }) + .passthrough(); + +export const rtpParametersSchema = z + .object({ + mid: z.string().optional(), + codecs: z.array(rtpParametersCodecSchema).min(1, 'rtpParameters.codecs must not be empty'), + headerExtensions: z.array(rtpHeaderExtensionParameterSchema).optional(), + encodings: z.array(rtpEncodingParametersSchema).optional(), + rtcp: z + .object({ + cname: z.string().optional(), + reducedSize: z.boolean().optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +export const rtpCapabilitiesSchema = z + .object({ + codecs: z + .array(rtpCapabilitiesCodecSchema) + .min(1, 'rtpCapabilities.codecs must not be empty'), + headerExtensions: z.array(z.unknown()).optional(), + }) + .passthrough(); + +export type RtpParametersInput = z.infer; +export type RtpCapabilitiesInput = z.infer; diff --git a/media-server/src/schemas/transport.schema.ts b/media-server/src/schemas/transport.schema.ts new file mode 100644 index 0000000..859e002 --- /dev/null +++ b/media-server/src/schemas/transport.schema.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +import { idStringSchema, userIdSchema } from './common.js'; + +export const transportDirectionSchema = z.enum(['send', 'recv']); + +export const createTransportBodySchema = z.object({ + routerId: idStringSchema, + userId: userIdSchema, + direction: transportDirectionSchema, +}); + +export const transportIdParamSchema = z.object({ + id: idStringSchema, +}); + +const dtlsFingerprintSchema = z.object({ + algorithm: z.enum([ + 'sha-1', + 'sha-224', + 'sha-256', + 'sha-384', + 'sha-512', + ]), + value: z.string().min(1), +}); + +const dtlsParametersSchema = z.object({ + role: z.enum(['auto', 'client', 'server']).optional(), + fingerprints: z.array(dtlsFingerprintSchema).min(1), +}); + +export const connectTransportBodySchema = z.object({ + dtlsParameters: dtlsParametersSchema, +}); + +export type CreateTransportBody = z.infer; +export type TransportIdParam = z.infer; +export type ConnectTransportBody = z.infer; +export type TransportDirection = z.infer; diff --git a/media-server/src/services/consumer.service.ts b/media-server/src/services/consumer.service.ts new file mode 100644 index 0000000..96b9f4a --- /dev/null +++ b/media-server/src/services/consumer.service.ts @@ -0,0 +1,150 @@ +import type { Consumer, MediaKind, RtpCapabilities, RtpParameters } from 'mediasoup/types'; + +import { AppError, notFound } from '../utils/errors.js'; +import { childLogger } from '../utils/logger.js'; +import { assertTestOnly } from '../utils/test-guard.js'; + +// 注:getProducer 仍然保留,既用于 notFound 语义(消费不存在的 producer 直接返回 404), +// 也用于后续 Phase 2e-3 需要预读 producer.appData 做资格校验 +import { getProducer } from './producer.service.js'; +import { getRouter } from './router.service.js'; +import { getTransportEntry } from './transport.service.js'; + +const log = childLogger({ module: 'services.consumer' }); + +interface ConsumerEntry { + consumer: Consumer; + transportId: string; + producerId: string; + routerId: string; + userId: string; + createdAt: number; +} + +const consumerMap = new Map(); + +export interface CreatedConsumerInfo { + id: string; + kind: MediaKind; + rtpParameters: RtpParameters; + producerPaused: boolean; +} + +export async function createConsumer(params: { + routerId: string; + transportId: string; + producerId: string; + rtpCapabilities: RtpCapabilities; +}): Promise { + const router = getRouter(params.routerId); + // 预检 producer 存在性;后续不再读 producer.paused,改用 consumer.producerPaused 更符合 mediasoup 语义 + getProducer(params.producerId); + const transportEntry = getTransportEntry(params.transportId); + + if (transportEntry.direction !== 'recv') { + throw new AppError( + 'CONFLICT', + `transport ${params.transportId} is not a recv transport (direction=${transportEntry.direction})`, + { transportId: params.transportId, direction: transportEntry.direction }, + ); + } + + if (!router.canConsume({ producerId: params.producerId, rtpCapabilities: params.rtpCapabilities })) { + throw new AppError( + 'CAN_NOT_CONSUME', + `router cannot consume producer ${params.producerId} with given rtpCapabilities`, + { routerId: params.routerId, producerId: params.producerId }, + ); + } + + try { + const consumer = await transportEntry.transport.consume({ + producerId: params.producerId, + rtpCapabilities: params.rtpCapabilities, + paused: true, + }); + + consumer.observer.once('close', () => { + consumerMap.delete(consumer.id); + log.info( + { consumerId: consumer.id, transportId: params.transportId }, + 'consumer closed and removed from map', + ); + }); + + consumer.once('producerclose', () => { + log.info({ consumerId: consumer.id }, 'upstream producer closed, closing consumer'); + consumer.close(); + }); + + consumerMap.set(consumer.id, { + consumer, + transportId: params.transportId, + producerId: params.producerId, + routerId: params.routerId, + userId: transportEntry.userId, + createdAt: Date.now(), + }); + + log.info( + { + consumerId: consumer.id, + transportId: params.transportId, + producerId: params.producerId, + kind: consumer.kind, + }, + 'consumer created (paused)', + ); + + return { + id: consumer.id, + kind: consumer.kind, + rtpParameters: consumer.rtpParameters, + producerPaused: consumer.producerPaused, + }; + } catch (err) { + if (err instanceof AppError) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + throw new AppError('MEDIASOUP_ERROR', `failed to create consumer: ${message}`, { + transportId: params.transportId, + producerId: params.producerId, + }); + } +} + +export async function resumeConsumer(consumerId: string): Promise { + const entry = consumerMap.get(consumerId); + if (!entry) { + throw notFound('consumer', consumerId); + } + await entry.consumer.resume(); + log.info({ consumerId }, 'consumer resumed'); +} + +export async function closeConsumer(consumerId: string): Promise { + const entry = consumerMap.get(consumerId); + if (!entry) { + throw notFound('consumer', consumerId); + } + entry.consumer.close(); + log.info({ consumerId }, 'consumer closed explicitly'); +} + +export function getConsumerStats(): { total: number } { + return { total: consumerMap.size }; +} + +/** 测试专用:复位 consumerMap,生产环境调用会抛错 */ +export function _clearConsumerMap(): void { + assertTestOnly('_clearConsumerMap'); + for (const entry of consumerMap.values()) { + try { + entry.consumer.close(); + } catch { + // already closed + } + } + consumerMap.clear(); +} diff --git a/media-server/src/services/producer.service.ts b/media-server/src/services/producer.service.ts new file mode 100644 index 0000000..8e893ee --- /dev/null +++ b/media-server/src/services/producer.service.ts @@ -0,0 +1,112 @@ +import type { MediaKind, Producer, RtpParameters } from 'mediasoup/types'; + +import { AppError, notFound } from '../utils/errors.js'; +import { childLogger } from '../utils/logger.js'; +import { assertTestOnly } from '../utils/test-guard.js'; + +import { getTransportEntry } from './transport.service.js'; + +const log = childLogger({ module: 'services.producer' }); + +interface ProducerEntry { + producer: Producer; + transportId: string; + userId: string; + createdAt: number; +} + +const producerMap = new Map(); + +export async function createProducer(params: { + transportId: string; + kind: MediaKind; + rtpParameters: RtpParameters; + appData?: Record; +}): Promise<{ id: string }> { + const entry = getTransportEntry(params.transportId); + if (entry.direction !== 'send') { + throw new AppError( + 'CONFLICT', + `transport ${params.transportId} is not a send transport (direction=${entry.direction})`, + { transportId: params.transportId, direction: entry.direction }, + ); + } + + try { + const producer = await entry.transport.produce({ + kind: params.kind, + rtpParameters: params.rtpParameters, + appData: { + ...(params.appData ?? {}), + userId: entry.userId, + }, + }); + + producer.observer.once('close', () => { + producerMap.delete(producer.id); + log.info( + { producerId: producer.id, transportId: params.transportId }, + 'producer closed and removed from map', + ); + }); + + producerMap.set(producer.id, { + producer, + transportId: params.transportId, + userId: entry.userId, + createdAt: Date.now(), + }); + + log.info( + { + producerId: producer.id, + transportId: params.transportId, + userId: entry.userId, + kind: params.kind, + }, + 'producer created', + ); + + return { id: producer.id }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new AppError('MEDIASOUP_ERROR', `failed to create producer: ${message}`, { + transportId: params.transportId, + kind: params.kind, + }); + } +} + +export function getProducer(producerId: string): Producer { + const entry = producerMap.get(producerId); + if (!entry) { + throw notFound('producer', producerId); + } + return entry.producer; +} + +export async function closeProducer(producerId: string): Promise { + const entry = producerMap.get(producerId); + if (!entry) { + throw notFound('producer', producerId); + } + entry.producer.close(); + log.info({ producerId }, 'producer closed explicitly'); +} + +export function getProducerStats(): { total: number } { + return { total: producerMap.size }; +} + +/** 测试专用:复位 producerMap,生产环境调用会抛错 */ +export function _clearProducerMap(): void { + assertTestOnly('_clearProducerMap'); + for (const entry of producerMap.values()) { + try { + entry.producer.close(); + } catch { + // already closed + } + } + producerMap.clear(); +} diff --git a/media-server/src/services/router.service.ts b/media-server/src/services/router.service.ts new file mode 100644 index 0000000..0b72a65 --- /dev/null +++ b/media-server/src/services/router.service.ts @@ -0,0 +1,100 @@ +import type { Router, RtpCapabilities } from 'mediasoup/types'; + +import { config } from '../config.js'; +import { MEDIA_CODECS } from '../mediasoup/codecs.js'; +import { getWorker } from '../mediasoup/worker.js'; +import { AppError, notFound } from '../utils/errors.js'; +import { childLogger } from '../utils/logger.js'; +import { assertTestOnly } from '../utils/test-guard.js'; + +const log = childLogger({ module: 'services.router' }); + +interface RouterEntry { + router: Router; + roomCode: string; + createdAt: number; +} + +const routerMap = new Map(); + +export async function createRouter(roomCode: string): Promise<{ + routerId: string; + rtpCapabilities: RtpCapabilities; +}> { + if (routerMap.size >= config.mediasoup.maxRouters) { + throw new AppError( + 'ROUTER_LIMIT_EXCEEDED', + `router limit reached: ${config.mediasoup.maxRouters}`, + { current: routerMap.size, max: config.mediasoup.maxRouters }, + ); + } + + const worker = getWorker(); + const router = await worker.createRouter({ mediaCodecs: MEDIA_CODECS }); + + router.observer.once('close', () => { + routerMap.delete(router.id); + log.info({ routerId: router.id, roomCode }, 'router closed and removed from map'); + }); + + routerMap.set(router.id, { + router, + roomCode, + createdAt: Date.now(), + }); + + log.info({ routerId: router.id, roomCode, total: routerMap.size }, 'router created'); + return { + routerId: router.id, + rtpCapabilities: router.rtpCapabilities, + }; +} + +export function getRouter(routerId: string): Router { + const entry = routerMap.get(routerId); + if (!entry) { + throw notFound('router', routerId); + } + return entry.router; +} + +export function tryGetRouter(routerId: string): Router | undefined { + return routerMap.get(routerId)?.router; +} + +export async function closeRouter(routerId: string): Promise { + const entry = routerMap.get(routerId); + if (!entry) { + throw notFound('router', routerId); + } + entry.router.close(); + log.info({ routerId, roomCode: entry.roomCode }, 'router closed explicitly'); +} + +export function getRouterStats(): { + total: number; + rooms: Array<{ routerId: string; roomCode: string; ageMs: number }>; +} { + const now = Date.now(); + return { + total: routerMap.size, + rooms: [...routerMap.entries()].map(([routerId, entry]) => ({ + routerId, + roomCode: entry.roomCode, + ageMs: now - entry.createdAt, + })), + }; +} + +/** 测试专用:复位 routerMap,生产环境调用会抛错 */ +export function _clearRouterMap(): void { + assertTestOnly('_clearRouterMap'); + for (const entry of routerMap.values()) { + try { + entry.router.close(); + } catch { + // already closed + } + } + routerMap.clear(); +} diff --git a/media-server/src/services/transport.service.ts b/media-server/src/services/transport.service.ts new file mode 100644 index 0000000..9e26690 --- /dev/null +++ b/media-server/src/services/transport.service.ts @@ -0,0 +1,157 @@ +import type { + DtlsParameters, + IceCandidate, + IceParameters, + WebRtcTransport, +} from 'mediasoup/types'; + +import { config } from '../config.js'; +import { AppError, conflict, notFound } from '../utils/errors.js'; +import { childLogger } from '../utils/logger.js'; +import { assertTestOnly } from '../utils/test-guard.js'; + +import { getRouter } from './router.service.js'; +import type { TransportDirection } from '../schemas/transport.schema.js'; + +const log = childLogger({ module: 'services.transport' }); + +interface TransportEntry { + transport: WebRtcTransport; + routerId: string; + userId: string; + direction: TransportDirection; + connected: boolean; + createdAt: number; +} + +const transportMap = new Map(); + +export interface CreatedTransportInfo { + id: string; + iceParameters: IceParameters; + iceCandidates: IceCandidate[]; + dtlsParameters: DtlsParameters; +} + +function buildListenIps(): Array<{ ip: string; announcedIp?: string }> { + const entry: { ip: string; announcedIp?: string } = { + ip: config.mediasoup.listenIp, + }; + if (config.mediasoup.announcedIp) { + entry.announcedIp = config.mediasoup.announcedIp; + } + return [entry]; +} + +export async function createWebRtcTransport(params: { + routerId: string; + userId: string; + direction: TransportDirection; +}): Promise { + const router = getRouter(params.routerId); + + const transport = await router.createWebRtcTransport({ + listenIps: buildListenIps(), + enableUdp: true, + enableTcp: true, + preferUdp: true, + initialAvailableOutgoingBitrate: 1_000_000, + appData: { + userId: params.userId, + direction: params.direction, + routerId: params.routerId, + }, + }); + + transport.observer.once('close', () => { + transportMap.delete(transport.id); + log.info( + { transportId: transport.id, routerId: params.routerId, userId: params.userId }, + 'transport closed and removed from map', + ); + }); + + transportMap.set(transport.id, { + transport, + routerId: params.routerId, + userId: params.userId, + direction: params.direction, + connected: false, + createdAt: Date.now(), + }); + + log.info( + { + transportId: transport.id, + routerId: params.routerId, + userId: params.userId, + direction: params.direction, + }, + 'webrtc transport created', + ); + + return { + id: transport.id, + iceParameters: transport.iceParameters, + iceCandidates: transport.iceCandidates, + dtlsParameters: transport.dtlsParameters, + }; +} + +export function getTransport(transportId: string): WebRtcTransport { + const entry = transportMap.get(transportId); + if (!entry) { + throw notFound('transport', transportId); + } + return entry.transport; +} + +export function getTransportEntry(transportId: string): TransportEntry { + const entry = transportMap.get(transportId); + if (!entry) { + throw notFound('transport', transportId); + } + return entry; +} + +export async function connectTransport(params: { + transportId: string; + dtlsParameters: DtlsParameters; +}): Promise { + const entry = transportMap.get(params.transportId); + if (!entry) { + throw notFound('transport', params.transportId); + } + // 乐观锁:先置位再 await,防止并发重复 connect 被 mediasoup 层包成 500 + if (entry.connected) { + throw conflict(`transport already connected: ${params.transportId}`); + } + entry.connected = true; + try { + await entry.transport.connect({ dtlsParameters: params.dtlsParameters }); + log.info({ transportId: params.transportId }, 'transport connected'); + } catch (err) { + entry.connected = false; + const message = err instanceof Error ? err.message : String(err); + throw new AppError('MEDIASOUP_ERROR', `failed to connect transport: ${message}`, { + transportId: params.transportId, + }); + } +} + +export function getTransportStats(): { total: number } { + return { total: transportMap.size }; +} + +/** 测试专用:复位 transportMap,生产环境调用会抛错 */ +export function _clearTransportMap(): void { + assertTestOnly('_clearTransportMap'); + for (const entry of transportMap.values()) { + try { + entry.transport.close(); + } catch { + // already closed + } + } + transportMap.clear(); +} diff --git a/media-server/src/utils/errors.ts b/media-server/src/utils/errors.ts new file mode 100644 index 0000000..717cb42 --- /dev/null +++ b/media-server/src/utils/errors.ts @@ -0,0 +1,44 @@ +export type AppErrorCode = + | 'NOT_FOUND' + | 'CONFLICT' + | 'ROUTER_LIMIT_EXCEEDED' + | 'CAN_NOT_CONSUME' + | 'MEDIASOUP_ERROR'; + +const ERROR_STATUS: Record = { + NOT_FOUND: 404, + CONFLICT: 409, + ROUTER_LIMIT_EXCEEDED: 503, + CAN_NOT_CONSUME: 400, + MEDIASOUP_ERROR: 500, +}; + +export class AppError extends Error { + readonly code: AppErrorCode; + readonly statusCode: number; + readonly details: Record | undefined; + + constructor(code: AppErrorCode, message: string, details?: Record) { + super(message); + this.name = 'AppError'; + this.code = code; + this.statusCode = ERROR_STATUS[code]; + this.details = details; + } + + toJSON(): Record { + return { + code: this.code, + message: this.message, + ...(this.details ? { details: this.details } : {}), + }; + } +} + +export function notFound(resource: string, id: string): AppError { + return new AppError('NOT_FOUND', `${resource} not found: ${id}`, { resource, id }); +} + +export function conflict(message: string, details?: Record): AppError { + return new AppError('CONFLICT', message, details); +} diff --git a/media-server/src/utils/logger.ts b/media-server/src/utils/logger.ts new file mode 100644 index 0000000..79737a3 --- /dev/null +++ b/media-server/src/utils/logger.ts @@ -0,0 +1,38 @@ +import pino, { type Logger, type LoggerOptions } from 'pino'; + +import { config } from '../config.js'; + +function buildLoggerOptions(): LoggerOptions { + const base: LoggerOptions = { + level: config.logLevel, + base: { + service: 'media-server', + pid: process.pid, + }, + timestamp: pino.stdTimeFunctions.isoTime, + redact: { + paths: ['req.headers["x-internal-token"]', 'headers["x-internal-token"]'], + censor: '[REDACTED]', + }, + }; + + if (config.logPretty) { + base.transport = { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:HH:MM:ss.l', + ignore: 'pid,hostname,service', + singleLine: false, + }, + }; + } + + return base; +} + +export const logger: Logger = pino(buildLoggerOptions()); + +export function childLogger(bindings: Record): Logger { + return logger.child(bindings); +} diff --git a/media-server/src/utils/test-guard.ts b/media-server/src/utils/test-guard.ts new file mode 100644 index 0000000..3c56d49 --- /dev/null +++ b/media-server/src/utils/test-guard.ts @@ -0,0 +1,13 @@ +/** + * 测试专用守卫:仅允许 NODE_ENV=test 调用 + * + * 所有下划线前缀的 _clearXxxMap / __TEST__* 等测试辅助函数必须在函数体首行调用 + * `assertTestOnly('')`,防止被误用到生产路径,造成资源全量销毁。 + */ +export function assertTestOnly(caller: string): void { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `[test-guard] ${caller} is test-only and must not be called in production (NODE_ENV=${process.env.NODE_ENV ?? 'undefined'})`, + ); + } +} diff --git a/media-server/tests/app.spec.ts b/media-server/tests/app.spec.ts new file mode 100644 index 0000000..afff615 --- /dev/null +++ b/media-server/tests/app.spec.ts @@ -0,0 +1,275 @@ +import type { FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { buildApp } from '../src/app.js'; +import { config } from '../src/config.js'; +import { closeWorker, startWorker } from '../src/mediasoup/worker.js'; +import { _clearConsumerMap } from '../src/services/consumer.service.js'; +import { _clearProducerMap } from '../src/services/producer.service.js'; +import { _clearRouterMap } from '../src/services/router.service.js'; +import { _clearTransportMap } from '../src/services/transport.service.js'; + +let app: FastifyInstance; +const token = config.internalToken; + +beforeAll(async () => { + app = (await buildApp()) as unknown as FastifyInstance; + await app.ready(); + await startWorker(); +}); + +afterAll(async () => { + _clearConsumerMap(); + _clearProducerMap(); + _clearTransportMap(); + _clearRouterMap(); + await app.close(); + await closeWorker(); +}); + +describe('HTTP layer: health + auth', () => { + it('GET /healthz returns ok snapshot', async () => { + const res = await app.inject({ method: 'GET', url: '/healthz' }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.service).toBe('media-server'); + expect(body.mediasoupVersion).toBeTruthy(); + }); + + it('GET /readyz returns 200 when worker ready', async () => { + const res = await app.inject({ method: 'GET', url: '/readyz' }); + expect(res.statusCode).toBe(200); + expect(res.json().ready).toBe(true); + }); + + it('GET /internal/info without token returns 401', async () => { + const res = await app.inject({ method: 'GET', url: '/internal/info' }); + expect(res.statusCode).toBe(401); + }); + + it('GET /internal/info with wrong token returns 401', async () => { + const res = await app.inject({ + method: 'GET', + url: '/internal/info', + headers: { 'x-internal-token': 'wrong-token-wrong-token' }, + }); + expect(res.statusCode).toBe(401); + }); + + it('GET /internal/info with valid token returns worker info', async () => { + const res = await app.inject({ + method: 'GET', + url: '/internal/info', + headers: { 'x-internal-token': token }, + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.service).toBe('media-server'); + expect(body.mediasoupVersion).toBeTruthy(); + }); +}); + +describe('HTTP layer: /internal/v1/* routes', () => { + let routerId = ''; + let sendTransportId = ''; + let recvTransportId = ''; + + it('POST /internal/v1/routers 201', async () => { + const res = await app.inject({ + method: 'POST', + url: '/internal/v1/routers', + headers: { 'x-internal-token': token }, + payload: { roomCode: 'ROOM-HTTP-1' }, + }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.routerId).toBeTruthy(); + routerId = body.routerId; + }); + + it('POST /internal/v1/routers validation error returns 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/internal/v1/routers', + headers: { 'x-internal-token': token }, + payload: { roomCode: 'lower-case' }, + }); + expect(res.statusCode).toBe(400); + const body = res.json(); + expect(body.code).toBe('VALIDATION_ERROR'); + expect(Array.isArray(body.fieldErrors)).toBe(true); + }); + + it('POST /internal/v1/transports (send) 201', async () => { + const res = await app.inject({ + method: 'POST', + url: '/internal/v1/transports', + headers: { 'x-internal-token': token }, + payload: { routerId, userId: 'u-1', direction: 'send' }, + }); + expect(res.statusCode).toBe(201); + sendTransportId = res.json().id; + expect(sendTransportId).toBeTruthy(); + }); + + it('POST /internal/v1/transports (recv) 201', async () => { + const res = await app.inject({ + method: 'POST', + url: '/internal/v1/transports', + headers: { 'x-internal-token': token }, + payload: { routerId, userId: 'u-2', direction: 'recv' }, + }); + expect(res.statusCode).toBe(201); + recvTransportId = res.json().id; + expect(recvTransportId).toBeTruthy(); + }); + + it('POST /internal/v1/transports with unknown router returns 404', async () => { + const res = await app.inject({ + method: 'POST', + url: '/internal/v1/transports', + headers: { 'x-internal-token': token }, + payload: { routerId: 'bogus-router', userId: 'u-3', direction: 'send' }, + }); + expect(res.statusCode).toBe(404); + expect(res.json().code).toBe('NOT_FOUND'); + }); + + const sampleAudioRtpParameters = { + codecs: [ + { mimeType: 'audio/opus', clockRate: 48000, channels: 2, payloadType: 100 }, + ], + encodings: [{ ssrc: 222222 }], + }; + + it('POST /internal/v1/producers with empty codecs returns 400 VALIDATION_ERROR', async () => { + const res = await app.inject({ + method: 'POST', + url: '/internal/v1/producers', + headers: { 'x-internal-token': token }, + payload: { + transportId: recvTransportId, + kind: 'audio', + rtpParameters: { codecs: [] }, + }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().code).toBe('VALIDATION_ERROR'); + }); + + it('POST /internal/v1/producers with recv transport returns 409', async () => { + const res = await app.inject({ + method: 'POST', + url: '/internal/v1/producers', + headers: { 'x-internal-token': token }, + payload: { + transportId: recvTransportId, + kind: 'audio', + rtpParameters: sampleAudioRtpParameters, + }, + }); + expect(res.statusCode).toBe(409); + expect(res.json().code).toBe('CONFLICT'); + }); + + it('POST /internal/v1/consumers with empty codecs returns 400 VALIDATION_ERROR', async () => { + const producerRes = await app.inject({ + method: 'POST', + url: '/internal/v1/producers', + headers: { 'x-internal-token': token }, + payload: { + transportId: sendTransportId, + kind: 'audio', + rtpParameters: sampleAudioRtpParameters, + }, + }); + expect(producerRes.statusCode).toBe(201); + const producerId = producerRes.json().id; + + const res = await app.inject({ + method: 'POST', + url: '/internal/v1/consumers', + headers: { 'x-internal-token': token }, + payload: { + routerId, + transportId: recvTransportId, + producerId, + rtpCapabilities: { codecs: [] }, + }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().code).toBe('VALIDATION_ERROR'); + }); + + it('POST /internal/v1/consumers with incompatible codecs returns 400 CAN_NOT_CONSUME', async () => { + const producerRes = await app.inject({ + method: 'POST', + url: '/internal/v1/producers', + headers: { 'x-internal-token': token }, + payload: { + transportId: sendTransportId, + kind: 'audio', + rtpParameters: { + ...sampleAudioRtpParameters, + encodings: [{ ssrc: 333333 }], + }, + }, + }); + expect(producerRes.statusCode).toBe(201); + const producerId = producerRes.json().id; + + const res = await app.inject({ + method: 'POST', + url: '/internal/v1/consumers', + headers: { 'x-internal-token': token }, + payload: { + routerId, + transportId: recvTransportId, + producerId, + rtpCapabilities: { + // 故意给一个 payloadType 不在 router 中的假 codec + codecs: [{ mimeType: 'audio/unknown-fake', clockRate: 48000 }], + }, + }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().code).toBe('CAN_NOT_CONSUME'); + }); + + it('DELETE /internal/v1/producers/:id returns 404 for unknown', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/internal/v1/producers/unknown-id', + headers: { 'x-internal-token': token }, + }); + expect(res.statusCode).toBe(404); + }); + + it('POST /internal/v1/consumers/:id/resume returns 404 for unknown', async () => { + const res = await app.inject({ + method: 'POST', + url: '/internal/v1/consumers/unknown-id/resume', + headers: { 'x-internal-token': token }, + }); + expect(res.statusCode).toBe(404); + }); + + it('DELETE /internal/v1/consumers/:id returns 404 for unknown', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/internal/v1/consumers/unknown-id', + headers: { 'x-internal-token': token }, + }); + expect(res.statusCode).toBe(404); + }); + + it('DELETE /internal/v1/routers/:routerId cleans up', async () => { + const res = await app.inject({ + method: 'DELETE', + url: `/internal/v1/routers/${routerId}`, + headers: { 'x-internal-token': token }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().ok).toBe(true); + }); +}); diff --git a/media-server/tests/errors.spec.ts b/media-server/tests/errors.spec.ts new file mode 100644 index 0000000..ee2362e --- /dev/null +++ b/media-server/tests/errors.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { AppError, conflict, notFound } from '../src/utils/errors.js'; + +describe('AppError', () => { + it('maps NOT_FOUND to 404', () => { + const err = notFound('router', 'r-1'); + expect(err).toBeInstanceOf(AppError); + expect(err.code).toBe('NOT_FOUND'); + expect(err.statusCode).toBe(404); + expect(err.message).toContain('r-1'); + expect(err.toJSON()).toMatchObject({ + code: 'NOT_FOUND', + message: expect.stringContaining('router'), + details: { resource: 'router', id: 'r-1' }, + }); + }); + + it('maps CONFLICT to 409', () => { + const err = conflict('already connected'); + expect(err.statusCode).toBe(409); + expect(err.code).toBe('CONFLICT'); + }); + + it('maps ROUTER_LIMIT_EXCEEDED to 503', () => { + const err = new AppError('ROUTER_LIMIT_EXCEEDED', 'too many routers'); + expect(err.statusCode).toBe(503); + }); + + it('maps CAN_NOT_CONSUME to 400', () => { + const err = new AppError('CAN_NOT_CONSUME', 'nope'); + expect(err.statusCode).toBe(400); + }); + + it('maps MEDIASOUP_ERROR to 500', () => { + const err = new AppError('MEDIASOUP_ERROR', 'boom'); + expect(err.statusCode).toBe(500); + }); + + it('toJSON omits details when not provided', () => { + const err = new AppError('CONFLICT', 'just a conflict'); + expect(err.toJSON()).toEqual({ code: 'CONFLICT', message: 'just a conflict' }); + }); +}); diff --git a/media-server/tests/schemas.spec.ts b/media-server/tests/schemas.spec.ts new file mode 100644 index 0000000..b833e29 --- /dev/null +++ b/media-server/tests/schemas.spec.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; + +import { + idStringSchema, + roomCodeSchema, + userIdSchema, +} from '../src/schemas/common.js'; +import { createConsumerBodySchema } from '../src/schemas/consumer.schema.js'; +import { createProducerBodySchema } from '../src/schemas/producer.schema.js'; +import { createRouterBodySchema } from '../src/schemas/router.schema.js'; +import { + connectTransportBodySchema, + createTransportBodySchema, +} from '../src/schemas/transport.schema.js'; + +describe('common schemas', () => { + it('accepts valid id strings', () => { + expect(idStringSchema.parse('abc-123_X:Y')).toBe('abc-123_X:Y'); + }); + + it('rejects invalid characters in id', () => { + expect(() => idStringSchema.parse('abc/def')).toThrow(); + expect(() => idStringSchema.parse('')).toThrow(); + expect(() => idStringSchema.parse('a'.repeat(200))).toThrow(); + }); + + it('accepts valid room codes', () => { + expect(roomCodeSchema.parse('ROOM-001')).toBe('ROOM-001'); + }); + + it('rejects lowercase or invalid room codes', () => { + expect(() => roomCodeSchema.parse('room-001')).toThrow(); + expect(() => roomCodeSchema.parse('AB')).toThrow(); + }); + + it('coerces numeric userIds into strings', () => { + expect(userIdSchema.parse(42)).toBe('42'); + expect(userIdSchema.parse('alice')).toBe('alice'); + }); + + it('rejects non-positive numeric userIds', () => { + expect(() => userIdSchema.parse(0)).toThrow(); + expect(() => userIdSchema.parse(-1)).toThrow(); + }); +}); + +describe('router schema', () => { + it('parses valid body', () => { + expect(createRouterBodySchema.parse({ roomCode: 'ROOM-1' })).toEqual({ + roomCode: 'ROOM-1', + }); + }); + + it('rejects missing roomCode', () => { + expect(() => createRouterBodySchema.parse({})).toThrow(); + }); +}); + +describe('transport schemas', () => { + it('parses create body', () => { + const parsed = createTransportBodySchema.parse({ + routerId: 'router-1', + userId: 'user-1', + direction: 'send', + }); + expect(parsed.direction).toBe('send'); + }); + + it('rejects bad direction', () => { + expect(() => + createTransportBodySchema.parse({ + routerId: 'router-1', + userId: 'user-1', + direction: 'bidirectional', + }), + ).toThrow(); + }); + + it('parses connect body with fingerprints', () => { + const parsed = connectTransportBodySchema.parse({ + dtlsParameters: { + role: 'auto', + fingerprints: [{ algorithm: 'sha-256', value: 'AA:BB' }], + }, + }); + expect(parsed.dtlsParameters.fingerprints).toHaveLength(1); + }); + + it('rejects connect body without fingerprints', () => { + expect(() => + connectTransportBodySchema.parse({ + dtlsParameters: { role: 'auto', fingerprints: [] }, + }), + ).toThrow(); + }); +}); + +describe('producer schema', () => { + const validRtpParameters = { + codecs: [ + { mimeType: 'audio/opus', clockRate: 48000, payloadType: 100, channels: 2 }, + ], + }; + + it('parses valid body', () => { + const parsed = createProducerBodySchema.parse({ + transportId: 'trans-1', + kind: 'audio', + rtpParameters: validRtpParameters, + }); + expect(parsed.kind).toBe('audio'); + expect(parsed.rtpParameters.codecs[0]?.payloadType).toBe(100); + }); + + it('rejects bad kind', () => { + expect(() => + createProducerBodySchema.parse({ + transportId: 'trans-1', + kind: 'data', + rtpParameters: validRtpParameters, + }), + ).toThrow(); + }); + + it('rejects rtpParameters without codecs', () => { + expect(() => + createProducerBodySchema.parse({ + transportId: 'trans-1', + kind: 'audio', + rtpParameters: {}, + }), + ).toThrow(); + }); + + it('rejects rtpParameters with empty codecs', () => { + expect(() => + createProducerBodySchema.parse({ + transportId: 'trans-1', + kind: 'audio', + rtpParameters: { codecs: [] }, + }), + ).toThrow(); + }); +}); + +describe('consumer schema', () => { + const validRtpCapabilities = { + codecs: [{ mimeType: 'audio/opus', clockRate: 48000 }], + }; + + it('parses valid body', () => { + const parsed = createConsumerBodySchema.parse({ + routerId: 'r-1', + transportId: 't-1', + producerId: 'p-1', + rtpCapabilities: validRtpCapabilities, + }); + expect(parsed.routerId).toBe('r-1'); + }); + + it('rejects missing fields', () => { + expect(() => + createConsumerBodySchema.parse({ routerId: 'r-1' }), + ).toThrow(); + }); + + it('rejects rtpCapabilities with empty codecs', () => { + expect(() => + createConsumerBodySchema.parse({ + routerId: 'r-1', + transportId: 't-1', + producerId: 'p-1', + rtpCapabilities: { codecs: [] }, + }), + ).toThrow(); + }); +}); diff --git a/media-server/tests/services/consumer.service.spec.ts b/media-server/tests/services/consumer.service.spec.ts new file mode 100644 index 0000000..c36a875 --- /dev/null +++ b/media-server/tests/services/consumer.service.spec.ts @@ -0,0 +1,87 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { closeWorker, startWorker } from '../../src/mediasoup/worker.js'; +import { + closeConsumer, + createConsumer, + resumeConsumer, + _clearConsumerMap, +} from '../../src/services/consumer.service.js'; +import { _clearProducerMap } from '../../src/services/producer.service.js'; +import { _clearRouterMap, createRouter } from '../../src/services/router.service.js'; +import { + _clearTransportMap, + createWebRtcTransport, +} from '../../src/services/transport.service.js'; +import { AppError } from '../../src/utils/errors.js'; + +let routerId = ''; +let sendTransportId = ''; +let recvTransportId = ''; + +beforeAll(async () => { + await startWorker(); + const r = await createRouter('ROOM-CONSUMER'); + routerId = r.routerId; + sendTransportId = ( + await createWebRtcTransport({ routerId, userId: 'u-s', direction: 'send' }) + ).id; + recvTransportId = ( + await createWebRtcTransport({ routerId, userId: 'u-r', direction: 'recv' }) + ).id; +}); + +afterAll(async () => { + _clearConsumerMap(); + _clearProducerMap(); + _clearTransportMap(); + _clearRouterMap(); + await closeWorker(); +}); + +describe('consumer.service', () => { + it('createConsumer with unknown router throws NOT_FOUND', async () => { + await expect( + createConsumer({ + routerId: 'bogus-router', + transportId: recvTransportId, + producerId: 'bogus-producer', + rtpCapabilities: {} as never, + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('createConsumer with unknown producer throws NOT_FOUND', async () => { + await expect( + createConsumer({ + routerId, + transportId: recvTransportId, + producerId: 'bogus-producer', + rtpCapabilities: {} as never, + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('createConsumer with unknown transport throws NOT_FOUND', async () => { + await expect( + createConsumer({ + routerId, + transportId: 'bogus-transport', + producerId: 'bogus-producer', + rtpCapabilities: {} as never, + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('resumeConsumer throws NOT_FOUND for unknown id', async () => { + await expect(resumeConsumer('non-existent')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('closeConsumer throws NOT_FOUND for unknown id', async () => { + await expect(closeConsumer('non-existent')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('placeholder: send transport exists for coverage', () => { + expect(sendTransportId).toBeTruthy(); + }); +}); diff --git a/media-server/tests/services/producer.service.spec.ts b/media-server/tests/services/producer.service.spec.ts new file mode 100644 index 0000000..a3a40e6 --- /dev/null +++ b/media-server/tests/services/producer.service.spec.ts @@ -0,0 +1,82 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { closeWorker, startWorker } from '../../src/mediasoup/worker.js'; +import { + closeProducer, + createProducer, + getProducer, + _clearProducerMap, +} from '../../src/services/producer.service.js'; +import { _clearRouterMap, createRouter } from '../../src/services/router.service.js'; +import { + _clearTransportMap, + createWebRtcTransport, +} from '../../src/services/transport.service.js'; +import { AppError } from '../../src/utils/errors.js'; + +let recvTransportId = ''; + +beforeAll(async () => { + await startWorker(); + const { routerId } = await createRouter('ROOM-PRODUCER'); + const recv = await createWebRtcTransport({ + routerId, + userId: 'user-recv', + direction: 'recv', + }); + recvTransportId = recv.id; +}); + +afterAll(async () => { + _clearProducerMap(); + _clearTransportMap(); + _clearRouterMap(); + await closeWorker(); +}); + +afterEach(() => { + _clearProducerMap(); +}); + +describe('producer.service', () => { + it('getProducer throws NOT_FOUND for unknown id', () => { + try { + getProducer('non-existent'); + throw new Error('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).code).toBe('NOT_FOUND'); + } + }); + + it('closeProducer throws NOT_FOUND for unknown id', async () => { + await expect(closeProducer('non-existent')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + const validRtpParameters = { + codecs: [ + { mimeType: 'audio/opus', clockRate: 48000, channels: 2, payloadType: 100 }, + ], + encodings: [{ ssrc: 111111 }], + } as never; + + it('createProducer rejects when transport direction is recv', async () => { + await expect( + createProducer({ + transportId: recvTransportId, + kind: 'audio', + rtpParameters: validRtpParameters, + }), + ).rejects.toMatchObject({ code: 'CONFLICT' }); + }); + + it('createProducer with unknown transport throws NOT_FOUND', async () => { + await expect( + createProducer({ + transportId: 'bogus-transport', + kind: 'audio', + rtpParameters: validRtpParameters, + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); +}); diff --git a/media-server/tests/services/router.service.spec.ts b/media-server/tests/services/router.service.spec.ts new file mode 100644 index 0000000..457221e --- /dev/null +++ b/media-server/tests/services/router.service.spec.ts @@ -0,0 +1,81 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { config } from '../../src/config.js'; +import { closeWorker, startWorker } from '../../src/mediasoup/worker.js'; +import { + _clearRouterMap, + closeRouter, + createRouter, + getRouter, + getRouterStats, + tryGetRouter, +} from '../../src/services/router.service.js'; +import { AppError } from '../../src/utils/errors.js'; + +beforeAll(async () => { + await startWorker(); +}); + +afterAll(async () => { + _clearRouterMap(); + await closeWorker(); +}); + +afterEach(() => { + _clearRouterMap(); +}); + +describe('router.service', () => { + it('creates a router and returns rtpCapabilities', async () => { + const { routerId, rtpCapabilities } = await createRouter('ROOM-TEST-1'); + expect(routerId).toBeTruthy(); + expect(Array.isArray(rtpCapabilities.codecs)).toBe(true); + expect(rtpCapabilities.codecs?.length).toBeGreaterThan(0); + + const stats = getRouterStats(); + expect(stats.total).toBe(1); + expect(stats.rooms[0]?.roomCode).toBe('ROOM-TEST-1'); + }); + + it('getRouter throws NOT_FOUND for unknown id', () => { + try { + getRouter('non-existent-id'); + throw new Error('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).code).toBe('NOT_FOUND'); + } + }); + + it('tryGetRouter returns undefined instead of throwing', () => { + expect(tryGetRouter('non-existent-id')).toBeUndefined(); + }); + + it('closeRouter removes entry via observer close event', async () => { + const { routerId } = await createRouter('ROOM-TEST-2'); + expect(getRouterStats().total).toBe(1); + + await closeRouter(routerId); + // observer.once('close') executes synchronously when router.close() is called + expect(getRouterStats().total).toBe(0); + }); + + it('closeRouter throws NOT_FOUND for unknown router', async () => { + await expect(closeRouter('non-existent-id')).rejects.toBeInstanceOf(AppError); + }); + + it('enforces router limit', async () => { + const originalLimit = config.mediasoup.maxRouters; + (config.mediasoup as { maxRouters: number }).maxRouters = 2; + + try { + await createRouter('ROOM-LIMIT-1'); + await createRouter('ROOM-LIMIT-2'); + await expect(createRouter('ROOM-LIMIT-3')).rejects.toMatchObject({ + code: 'ROUTER_LIMIT_EXCEEDED', + }); + } finally { + (config.mediasoup as { maxRouters: number }).maxRouters = originalLimit; + } + }); +}); diff --git a/media-server/tests/services/transport.service.spec.ts b/media-server/tests/services/transport.service.spec.ts new file mode 100644 index 0000000..e6fa4e1 --- /dev/null +++ b/media-server/tests/services/transport.service.spec.ts @@ -0,0 +1,75 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { closeWorker, startWorker } from '../../src/mediasoup/worker.js'; +import { _clearRouterMap, createRouter } from '../../src/services/router.service.js'; +import { + _clearTransportMap, + createWebRtcTransport, + getTransport, + getTransportStats, +} from '../../src/services/transport.service.js'; +import { AppError } from '../../src/utils/errors.js'; + +let routerId = ''; + +beforeAll(async () => { + await startWorker(); + const r = await createRouter('ROOM-TRANSPORT'); + routerId = r.routerId; +}); + +afterAll(async () => { + _clearTransportMap(); + _clearRouterMap(); + await closeWorker(); +}); + +afterEach(() => { + _clearTransportMap(); +}); + +describe('transport.service', () => { + it('creates a WebRTC transport and returns ICE/DTLS params', async () => { + const transport = await createWebRtcTransport({ + routerId, + userId: 'user-1', + direction: 'send', + }); + expect(transport.id).toBeTruthy(); + expect(transport.iceParameters.usernameFragment).toBeTruthy(); + expect(transport.iceParameters.password).toBeTruthy(); + expect(Array.isArray(transport.iceCandidates)).toBe(true); + expect(transport.iceCandidates.length).toBeGreaterThan(0); + expect(transport.dtlsParameters.fingerprints.length).toBeGreaterThan(0); + expect(getTransportStats().total).toBe(1); + }); + + it('supports recv direction', async () => { + const transport = await createWebRtcTransport({ + routerId, + userId: 'user-2', + direction: 'recv', + }); + expect(transport.id).toBeTruthy(); + }); + + it('getTransport throws NOT_FOUND for unknown id', () => { + try { + getTransport('non-existent'); + throw new Error('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(AppError); + expect((err as AppError).code).toBe('NOT_FOUND'); + } + }); + + it('createWebRtcTransport with unknown routerId throws NOT_FOUND', async () => { + await expect( + createWebRtcTransport({ + routerId: 'bogus-router', + userId: 'user-3', + direction: 'send', + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); +}); diff --git a/media-server/tests/setup.ts b/media-server/tests/setup.ts new file mode 100644 index 0000000..0b72aaa --- /dev/null +++ b/media-server/tests/setup.ts @@ -0,0 +1,7 @@ +process.env.NODE_ENV = 'test'; +process.env.MEDIA_INTERNAL_TOKEN = process.env.MEDIA_INTERNAL_TOKEN ?? 'test-token-1234567890'; +process.env.LOG_LEVEL = process.env.LOG_LEVEL ?? 'silent'; +process.env.LOG_PRETTY = process.env.LOG_PRETTY ?? 'false'; +process.env.MEDIASOUP_RTC_MIN_PORT = process.env.MEDIASOUP_RTC_MIN_PORT ?? '40800'; +process.env.MEDIASOUP_RTC_MAX_PORT = process.env.MEDIASOUP_RTC_MAX_PORT ?? '40899'; +process.env.MEDIASOUP_WORKER_LOG_LEVEL = process.env.MEDIASOUP_WORKER_LOG_LEVEL ?? 'error'; diff --git a/media-server/tests/test-guard.spec.ts b/media-server/tests/test-guard.spec.ts new file mode 100644 index 0000000..7046ef9 --- /dev/null +++ b/media-server/tests/test-guard.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { assertTestOnly } from '../src/utils/test-guard.js'; + +describe('assertTestOnly', () => { + it('passes when NODE_ENV=test', () => { + expect(() => assertTestOnly('sampleFn')).not.toThrow(); + }); + + it('throws when NODE_ENV is not test', () => { + const original = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + expect(() => assertTestOnly('sampleFn')).toThrow(/test-only/); + } finally { + process.env.NODE_ENV = original; + } + }); +}); diff --git a/media-server/tsconfig.build.json b/media-server/tsconfig.build.json new file mode 100644 index 0000000..a69aef8 --- /dev/null +++ b/media-server/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": false + }, + "exclude": ["node_modules", "dist", "tests", "poc", "**/*.spec.ts", "src/**/__tests__/**"] +} diff --git a/media-server/tsconfig.json b/media-server/tsconfig.json new file mode 100644 index 0000000..9f9d65a --- /dev/null +++ b/media-server/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests", "poc", "**/*.spec.ts"] +} diff --git a/media-server/vitest.config.ts b/media-server/vitest.config.ts new file mode 100644 index 0000000..cc91076 --- /dev/null +++ b/media-server/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['tests/**/*.spec.ts'], + setupFiles: ['./tests/setup.ts'], + testTimeout: 15_000, + hookTimeout: 30_000, + pool: 'forks', + coverage: { + reporter: ['text', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/app.ts', 'src/config.ts'], + }, + }, +});