From aae91ac862939181f2c8f7c5787924f137ae4f5b Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Sat, 7 Feb 2026 17:30:00 +0000 Subject: [PATCH] feat(media): add single-server SFU scaffolding and media mode config --- Backend/media/config.ts | 43 ++++++++++++++++++++++ Backend/media/sfu/noop.ts | 71 ++++++++++++++++++++++++++++++++++++ Backend/media/sfu/service.ts | 14 +++++++ Backend/media/sfu/types.ts | 47 ++++++++++++++++++++++++ Backend/routes/ops.ts | 4 ++ 5 files changed, 179 insertions(+) create mode 100644 Backend/media/config.ts create mode 100644 Backend/media/sfu/noop.ts create mode 100644 Backend/media/sfu/service.ts create mode 100644 Backend/media/sfu/types.ts diff --git a/Backend/media/config.ts b/Backend/media/config.ts new file mode 100644 index 0000000..9632e3b --- /dev/null +++ b/Backend/media/config.ts @@ -0,0 +1,43 @@ +export type MediaMode = 'legacy' | 'single_server_sfu'; + +const parseMediaMode = (value: string | undefined): MediaMode => { + const normalized = (value ?? 'legacy').trim().toLowerCase(); + if (normalized === 'single_server_sfu') { + return 'single_server_sfu'; + } + return 'legacy'; +}; + +const parseCsv = (value: string | undefined): string[] => { + if (!value) return []; + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +}; + +const parsePositiveNumber = (value: string | undefined): number | null => { + if (!value) return null; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +}; + +export const mediaMode: MediaMode = parseMediaMode(process.env.MEDIA_MODE); + +export const mediaConfig = { + mode: mediaMode, + turn: { + urls: parseCsv(process.env.TURN_URLS), + username: process.env.TURN_USERNAME ?? '', + credential: process.env.TURN_CREDENTIAL ?? '', + }, + recordingsDir: process.env.MEDIA_RECORDINGS_DIR ?? 'media-recordings', + limits: { + maxPublishers: parsePositiveNumber(process.env.MEDIA_MAX_PUBLISHERS), + maxSubscribersPerRoom: parsePositiveNumber(process.env.MEDIA_MAX_SUBSCRIBERS_PER_ROOM), + }, +}; + diff --git a/Backend/media/sfu/noop.ts b/Backend/media/sfu/noop.ts new file mode 100644 index 0000000..4f216a7 --- /dev/null +++ b/Backend/media/sfu/noop.ts @@ -0,0 +1,71 @@ +import { randomUUID } from 'crypto'; + +import { mediaConfig } from '../config'; +import type { + SfuPublishTransportRequest, + SfuPublishTransportResult, + SfuService, + SfuSessionDescriptor, + SfuSessionStartInput, + SfuSubscribeTransportRequest, + SfuSubscribeTransportResult, +} from './types'; + +const toIceServers = (): Array<{ urls: string; username?: string; credential?: string }> => { + if (mediaConfig.turn.urls.length === 0) { + return []; + } + + return mediaConfig.turn.urls.map((urls) => ({ + urls, + ...(mediaConfig.turn.username ? { username: mediaConfig.turn.username } : {}), + ...(mediaConfig.turn.credential ? { credential: mediaConfig.turn.credential } : {}), + })); +}; + +export class NoopSfuService implements SfuService { + mode: 'single_server_sfu' = 'single_server_sfu'; + private readonly sessions = new Map(); + + async startSession(input: SfuSessionStartInput): Promise { + const now = new Date().toISOString(); + const existing = this.sessions.get(input.streamSessionId); + if (existing) return existing; + + const descriptor: SfuSessionDescriptor = { + streamSessionId: input.streamSessionId, + ownerUserId: input.ownerUserId, + cameraDeviceId: input.cameraDeviceId, + requesterDeviceId: input.requesterDeviceId, + state: 'starting', + createdAt: now, + }; + this.sessions.set(input.streamSessionId, descriptor); + return descriptor; + } + + async endSession(streamSessionId: string): Promise { + const existing = this.sessions.get(streamSessionId); + if (!existing) return; + this.sessions.set(streamSessionId, { ...existing, state: 'ended' }); + } + + async getSession(streamSessionId: string): Promise { + return this.sessions.get(streamSessionId) ?? null; + } + + async createPublishTransport(_input: SfuPublishTransportRequest): Promise { + return { + transportId: `pub_${randomUUID()}`, + iceServers: toIceServers(), + }; + } + + async createSubscribeTransport(_input: SfuSubscribeTransportRequest): Promise { + return { + transportId: `sub_${randomUUID()}`, + iceServers: toIceServers(), + }; + } +} + diff --git a/Backend/media/sfu/service.ts b/Backend/media/sfu/service.ts new file mode 100644 index 0000000..2dec3c6 --- /dev/null +++ b/Backend/media/sfu/service.ts @@ -0,0 +1,14 @@ +import { mediaMode } from '../config'; +import { NoopSfuService } from './noop'; +import type { SfuService } from './types'; + +const createSfuService = (): SfuService | null => { + if (mediaMode !== 'single_server_sfu') { + return null; + } + + return new NoopSfuService(); +}; + +export const sfuService = createSfuService(); + diff --git a/Backend/media/sfu/types.ts b/Backend/media/sfu/types.ts new file mode 100644 index 0000000..fa13385 --- /dev/null +++ b/Backend/media/sfu/types.ts @@ -0,0 +1,47 @@ +export type SfuSessionState = 'idle' | 'starting' | 'live' | 'ending' | 'ended'; + +export type SfuSessionDescriptor = { + streamSessionId: string; + ownerUserId: string; + cameraDeviceId: string; + requesterDeviceId: string; + state: SfuSessionState; + createdAt: string; +}; + +export type SfuSessionStartInput = { + streamSessionId: string; + ownerUserId: string; + cameraDeviceId: string; + requesterDeviceId: string; +}; + +export type SfuPublishTransportRequest = { + streamSessionId: string; + cameraDeviceId: string; +}; + +export type SfuPublishTransportResult = { + transportId: string; + iceServers: Array<{ urls: string; username?: string; credential?: string }>; +}; + +export type SfuSubscribeTransportRequest = { + streamSessionId: string; + viewerDeviceId: string; +}; + +export type SfuSubscribeTransportResult = { + transportId: string; + iceServers: Array<{ urls: string; username?: string; credential?: string }>; +}; + +export interface SfuService { + mode: 'single_server_sfu'; + startSession(input: SfuSessionStartInput): Promise; + endSession(streamSessionId: string): Promise; + getSession(streamSessionId: string): Promise; + createPublishTransport(input: SfuPublishTransportRequest): Promise; + createSubscribeTransport(input: SfuSubscribeTransportRequest): Promise; +} + diff --git a/Backend/routes/ops.ts b/Backend/routes/ops.ts index 8c44c49..a7ad64e 100644 --- a/Backend/routes/ops.ts +++ b/Backend/routes/ops.ts @@ -1,7 +1,9 @@ import { Router } from 'express'; import { db } from '../db/client'; +import { mediaConfig } from '../media/config'; import { mediaProvider } from '../media/service'; +import { sfuService } from '../media/sfu/service'; import { getAllMetrics } from '../observability/metrics'; import { minioBucket, minioClient } from '../utils/minio'; @@ -21,7 +23,9 @@ router.get('/ready', async (_req, res) => { checks: { database: 'ok', minio: 'ok', + mediaMode: mediaConfig.mode, mediaProvider: mediaProvider.name, + sfuService: sfuService ? sfuService.mode : 'disabled', }, timestamp: new Date().toISOString(), });