202 lines
7.7 KiB
TypeScript
202 lines
7.7 KiB
TypeScript
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<SfuSessionDescriptor> {
|
|
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<void> {
|
|
this.registry.updateState(streamSessionId, state);
|
|
}
|
|
|
|
async endSession(streamSessionId: string): Promise<void> {
|
|
this.registry.updateState(streamSessionId, 'ending');
|
|
this.registry.updateState(streamSessionId, 'ended');
|
|
}
|
|
|
|
async getSession(streamSessionId: string): Promise<SfuSessionDescriptor | null> {
|
|
return this.registry.get(streamSessionId);
|
|
}
|
|
|
|
async getRouterRtpCapabilities(_streamSessionId: string): Promise<Record<string, unknown> | null> {
|
|
return {
|
|
codecs: [{ mimeType: 'video/VP8', clockRate: 90000, kind: 'video' }],
|
|
headerExtensions: [],
|
|
};
|
|
}
|
|
|
|
async listSessions(): Promise<SfuSessionDescriptor[]> {
|
|
return this.registry.list();
|
|
}
|
|
|
|
async listTransports(streamSessionId: string): Promise<SfuTransportDescriptor[]> {
|
|
return this.registry.listTransports(streamSessionId);
|
|
}
|
|
|
|
async listProducers(streamSessionId: string): Promise<SfuProducerDescriptor[]> {
|
|
return this.registry.listProducers(streamSessionId);
|
|
}
|
|
|
|
async listConsumers(streamSessionId: string): Promise<SfuConsumerDescriptor[]> {
|
|
return this.registry.listConsumers(streamSessionId);
|
|
}
|
|
|
|
async createPublishTransport(input: SfuPublishTransportRequest): Promise<SfuPublishTransportResult> {
|
|
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<SfuSubscribeTransportResult> {
|
|
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<SfuTransportDescriptor> {
|
|
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<SfuTransportDescriptor> {
|
|
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<SfuProducerDescriptor> {
|
|
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<SfuConsumerDescriptor> {
|
|
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,
|
|
});
|
|
}
|
|
}
|