import { randomUUID } from 'crypto'; import { mediaConfig } from '../config'; import { SfuSessionRegistry } from './registry'; import type { SfuConnectTransportInput, SfuConsumeInput, SfuConsumerDescriptor, SfuIceServer, SfuProduceInput, SfuProducerDescriptor, SfuPublishTransportRequest, SfuPublishTransportResult, SfuService, SfuSessionDescriptor, SfuSessionStartInput, SfuSubscribeTransportRequest, SfuSubscribeTransportResult, SfuTransportDescriptor, } from './types'; const toIceServers = (): SfuIceServer[] => { 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 registry = new SfuSessionRegistry(); async startSession(input: SfuSessionStartInput): Promise { const now = new Date().toISOString(); const existing = this.registry.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, }; return this.registry.set(descriptor); } async setSessionState(streamSessionId: string, state: SfuSessionDescriptor['state']): Promise { this.registry.updateState(streamSessionId, state); } async endSession(streamSessionId: string): Promise { this.registry.updateState(streamSessionId, 'ending'); this.registry.updateState(streamSessionId, 'ended'); } async getSession(streamSessionId: string): Promise { return this.registry.get(streamSessionId); } async getRouterRtpCapabilities(_streamSessionId: string): Promise | null> { return { codecs: [{ mimeType: 'video/VP8', clockRate: 90000, kind: 'video' }], headerExtensions: [], }; } async listSessions(): Promise { return this.registry.list(); } async listTransports(streamSessionId: string): Promise { return this.registry.listTransports(streamSessionId); } async listProducers(streamSessionId: string): Promise { return this.registry.listProducers(streamSessionId); } async listConsumers(streamSessionId: string): Promise { return this.registry.listConsumers(streamSessionId); } async createPublishTransport(input: SfuPublishTransportRequest): Promise { const transportId = `pub_${randomUUID()}`; this.registry.addTransport({ transportId, streamSessionId: input.streamSessionId, ownerDeviceId: input.cameraDeviceId, direction: 'publish', }); return { transportId, iceServers: toIceServers(), transportOptions: { id: transportId, iceParameters: {}, iceCandidates: [], dtlsParameters: {}, }, }; } async createSubscribeTransport(input: SfuSubscribeTransportRequest): Promise { const transportId = `sub_${randomUUID()}`; this.registry.addTransport({ transportId, streamSessionId: input.streamSessionId, ownerDeviceId: input.viewerDeviceId, direction: 'subscribe', }); return { transportId, iceServers: toIceServers(), transportOptions: { id: transportId, iceParameters: {}, iceCandidates: [], dtlsParameters: {}, }, }; } async connectPublishTransport(input: SfuConnectTransportInput): Promise { const transport = this.registry.getTransport(input.transportId); if (!transport) throw new Error('Publish transport not found'); if (transport.streamSessionId !== input.streamSessionId) throw new Error('Transport does not belong to stream'); if (transport.direction !== 'publish') throw new Error('Transport is not a publish transport'); if (transport.ownerDeviceId !== input.deviceId) throw new Error('Device does not own this publish transport'); const connected = this.registry.connectTransport(input.transportId); if (!connected) throw new Error('Publish transport connect failed'); return connected; } async connectSubscribeTransport(input: SfuConnectTransportInput): Promise { const transport = this.registry.getTransport(input.transportId); if (!transport) throw new Error('Subscribe transport not found'); if (transport.streamSessionId !== input.streamSessionId) throw new Error('Transport does not belong to stream'); if (transport.direction !== 'subscribe') throw new Error('Transport is not a subscribe transport'); if (transport.ownerDeviceId !== input.deviceId) throw new Error('Device does not own this subscribe transport'); const connected = this.registry.connectTransport(input.transportId); if (!connected) throw new Error('Subscribe transport connect failed'); return connected; } async produce(input: SfuProduceInput): Promise { const transport = this.registry.getTransport(input.transportId); if (!transport) throw new Error('Publish transport not found'); if (transport.streamSessionId !== input.streamSessionId) throw new Error('Transport does not belong to stream'); if (transport.direction !== 'publish') throw new Error('Transport is not a publish transport'); if (transport.ownerDeviceId !== input.cameraDeviceId) throw new Error('Device does not own this publish transport'); if (transport.state !== 'connected') throw new Error('Publish transport must be connected before producing'); return this.registry.addProducer({ producerId: `prod_${randomUUID()}`, streamSessionId: input.streamSessionId, transportId: input.transportId, cameraDeviceId: input.cameraDeviceId, kind: input.kind, rtpParameters: input.rtpParameters, }); } async consume(input: SfuConsumeInput): Promise { const transport = this.registry.getTransport(input.transportId); if (!transport) throw new Error('Subscribe transport not found'); if (transport.streamSessionId !== input.streamSessionId) throw new Error('Transport does not belong to stream'); if (transport.direction !== 'subscribe') throw new Error('Transport is not a subscribe transport'); if (transport.ownerDeviceId !== input.viewerDeviceId) throw new Error('Device does not own this subscribe transport'); if (transport.state !== 'connected') throw new Error('Subscribe transport must be connected before consuming'); const selectedProducer = (input.producerId ? this.registry.getProducer(input.producerId) : null) ?? this.registry .listProducers(input.streamSessionId) .slice() .reverse() .find((producer) => producer.kind === 'video'); if (!selectedProducer) throw new Error('No producer available for consume'); if (selectedProducer.streamSessionId !== input.streamSessionId) throw new Error('Producer does not belong to stream'); return this.registry.addConsumer({ consumerId: `cons_${randomUUID()}`, streamSessionId: input.streamSessionId, transportId: input.transportId, viewerDeviceId: input.viewerDeviceId, producerId: selectedProducer.producerId, kind: selectedProducer.kind, rtpParameters: selectedProducer.rtpParameters, }); } }