feat(phase2e-2): media-server Task 0/1/2 落地 + 代码审查修复

本次提交一次性落盘 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
This commit is contained in:
bujinyuan
2026-04-21 15:31:01 +08:00
parent df538e8df6
commit 6e2e792db0
56 changed files with 11123 additions and 51 deletions

145
media-server/src/app.ts Normal file
View File

@@ -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<void> {
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<void> => {
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();
}

125
media-server/src/config.ts Normal file
View File

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

View File

@@ -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,
},
},
];

View File

@@ -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<Worker> | 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<Worker> {
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<Worker> {
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<void> {
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',
);
}
}
}

View File

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

View File

@@ -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<void> {
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',
},
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof okResponseSchema>;

View File

@@ -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<typeof createConsumerBodySchema>;
export type ConsumerIdParam = z.infer<typeof consumerIdParamSchema>;

View File

@@ -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<typeof createProducerBodySchema>;
export type ProducerIdParam = z.infer<typeof producerIdParamSchema>;
export type MediaKind = z.infer<typeof mediaKindSchema>;

View File

@@ -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<typeof createRouterBodySchema>;
export type RouterIdParam = z.infer<typeof routerIdParamSchema>;

View File

@@ -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<typeof rtpParametersSchema>;
export type RtpCapabilitiesInput = z.infer<typeof rtpCapabilitiesSchema>;

View File

@@ -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<typeof createTransportBodySchema>;
export type TransportIdParam = z.infer<typeof transportIdParamSchema>;
export type ConnectTransportBody = z.infer<typeof connectTransportBodySchema>;
export type TransportDirection = z.infer<typeof transportDirectionSchema>;

View File

@@ -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<string, ConsumerEntry>();
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<CreatedConsumerInfo> {
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<void> {
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<void> {
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();
}

View File

@@ -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<string, ProducerEntry>();
export async function createProducer(params: {
transportId: string;
kind: MediaKind;
rtpParameters: RtpParameters;
appData?: Record<string, unknown>;
}): 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<void> {
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();
}

View File

@@ -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<string, RouterEntry>();
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<void> {
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();
}

View File

@@ -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<string, TransportEntry>();
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<CreatedTransportInfo> {
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<void> {
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();
}

View File

@@ -0,0 +1,44 @@
export type AppErrorCode =
| 'NOT_FOUND'
| 'CONFLICT'
| 'ROUTER_LIMIT_EXCEEDED'
| 'CAN_NOT_CONSUME'
| 'MEDIASOUP_ERROR';
const ERROR_STATUS: Record<AppErrorCode, number> = {
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<string, unknown> | undefined;
constructor(code: AppErrorCode, message: string, details?: Record<string, unknown>) {
super(message);
this.name = 'AppError';
this.code = code;
this.statusCode = ERROR_STATUS[code];
this.details = details;
}
toJSON(): Record<string, unknown> {
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<string, unknown>): AppError {
return new AppError('CONFLICT', message, details);
}

View File

@@ -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<string, unknown>): Logger {
return logger.child(bindings);
}

View File

@@ -0,0 +1,13 @@
/**
* 测试专用守卫:仅允许 NODE_ENV=test 调用
*
* 所有下划线前缀的 _clearXxxMap / __TEST__* 等测试辅助函数必须在函数体首行调用
* `assertTestOnly('<caller>')`,防止被误用到生产路径,造成资源全量销毁。
*/
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'})`,
);
}
}