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:
145
media-server/src/app.ts
Normal file
145
media-server/src/app.ts
Normal 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
125
media-server/src/config.ts
Normal 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;
|
||||
33
media-server/src/mediasoup/codecs.ts
Normal file
33
media-server/src/mediasoup/codecs.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
];
|
||||
132
media-server/src/mediasoup/worker.ts
Normal file
132
media-server/src/mediasoup/worker.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
media-server/src/middlewares/error-handler.ts
Normal file
58
media-server/src/middlewares/error-handler.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
}
|
||||
57
media-server/src/middlewares/internal-auth.ts
Normal file
57
media-server/src/middlewares/internal-auth.ts
Normal 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',
|
||||
},
|
||||
);
|
||||
31
media-server/src/routes/consumer.route.ts
Normal file
31
media-server/src/routes/consumer.route.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
25
media-server/src/routes/producer.route.ts
Normal file
25
media-server/src/routes/producer.route.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
19
media-server/src/routes/router.route.ts
Normal file
19
media-server/src/routes/router.route.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
28
media-server/src/routes/transport.route.ts
Normal file
28
media-server/src/routes/transport.route.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
23
media-server/src/schemas/common.ts
Normal file
23
media-server/src/schemas/common.ts
Normal 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>;
|
||||
18
media-server/src/schemas/consumer.schema.ts
Normal file
18
media-server/src/schemas/consumer.schema.ts
Normal 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>;
|
||||
21
media-server/src/schemas/producer.schema.ts
Normal file
21
media-server/src/schemas/producer.schema.ts
Normal 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>;
|
||||
14
media-server/src/schemas/router.schema.ts
Normal file
14
media-server/src/schemas/router.schema.ts
Normal 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>;
|
||||
84
media-server/src/schemas/rtp.ts
Normal file
84
media-server/src/schemas/rtp.ts
Normal 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>;
|
||||
40
media-server/src/schemas/transport.schema.ts
Normal file
40
media-server/src/schemas/transport.schema.ts
Normal 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>;
|
||||
150
media-server/src/services/consumer.service.ts
Normal file
150
media-server/src/services/consumer.service.ts
Normal 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();
|
||||
}
|
||||
112
media-server/src/services/producer.service.ts
Normal file
112
media-server/src/services/producer.service.ts
Normal 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();
|
||||
}
|
||||
100
media-server/src/services/router.service.ts
Normal file
100
media-server/src/services/router.service.ts
Normal 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();
|
||||
}
|
||||
157
media-server/src/services/transport.service.ts
Normal file
157
media-server/src/services/transport.service.ts
Normal 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();
|
||||
}
|
||||
44
media-server/src/utils/errors.ts
Normal file
44
media-server/src/utils/errors.ts
Normal 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);
|
||||
}
|
||||
38
media-server/src/utils/logger.ts
Normal file
38
media-server/src/utils/logger.ts
Normal 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);
|
||||
}
|
||||
13
media-server/src/utils/test-guard.ts
Normal file
13
media-server/src/utils/test-guard.ts
Normal 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'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user