diff --git a/apps/dispatch-server/index.ts b/apps/dispatch-server/index.ts index 21597f3f..9a05a1d6 100644 --- a/apps/dispatch-server/index.ts +++ b/apps/dispatch-server/index.ts @@ -6,6 +6,8 @@ import { createAdapter } from "@socket.io/redis-adapter"; import { jwtMiddleware } from "modules/socketJWTmiddleware"; import { pubClient, subClient } from "modules/redis"; import { handle } from "socket-events/connect-dispatch"; +import router from "routes/router"; +import cors from "cors"; const app = express(); const server = createServer(app); @@ -14,12 +16,15 @@ const io = new Server(server, { adapter: createAdapter(pubClient, subClient), cors: {}, }); - io.use(jwtMiddleware); io.on("connection", (socket) => { socket.on("connect-dispatch", handle(socket, io)); }); + +app.use(cors()); +app.use(router); + server.listen(process.env.PORT, () => { console.log(`Server running on port ${process.env.PORT}`); }); diff --git a/apps/dispatch-server/modules/redis.ts b/apps/dispatch-server/modules/redis.ts index 02151d4a..48b2e034 100644 --- a/apps/dispatch-server/modules/redis.ts +++ b/apps/dispatch-server/modules/redis.ts @@ -5,4 +5,13 @@ export const subClient = pubClient.duplicate(); Promise.all([pubClient.connect(), subClient.connect()]).then(() => { console.log("Redis connected"); + pubClient.keys("dispatchers*").then((keys) => { + if (!keys) return; + keys.forEach(async (key) => { + await pubClient.json.del(key); + }); + }); }); + +pubClient.on("error", (err) => console.log("Redis Client Error", err)); +subClient.on("error", (err) => console.log("Redis Client Error", err)); diff --git a/apps/dispatch-server/package.json b/apps/dispatch-server/package.json index 2eaacc9e..6abd9af8 100644 --- a/apps/dispatch-server/package.json +++ b/apps/dispatch-server/package.json @@ -21,10 +21,12 @@ "@redis/json": "^1.0.7", "@socket.io/redis-adapter": "^8.3.0", "axios": "^1.7.9", + "cors": "^2.8.5", "cron": "^4.1.0", "dotenv": "^16.4.7", "express": "^4.21.2", "jsonwebtoken": "^9.0.2", + "livekit-server-sdk": "^2.10.2", "nodemailer": "^6.10.0", "react": "^19.0.0", "redis": "^4.7.0", diff --git a/apps/dispatch-server/routes/livekit.ts b/apps/dispatch-server/routes/livekit.ts new file mode 100644 index 00000000..751cedc8 --- /dev/null +++ b/apps/dispatch-server/routes/livekit.ts @@ -0,0 +1,38 @@ +import { Router } from "express"; +import { AccessToken } from "livekit-server-sdk"; + +if (!process.env.LIVEKIT_API_KEY) throw new Error("LIVEKIT_API_KEY not set"); +if (!process.env.LIVEKIT_API_SECRET) + throw new Error("LIVEKIT_API_SECRET not set"); + +const createToken = async () => { + // If this room doesn't exist, it'll be automatically created when the first + // participant joins + const roomName = "quickstart-room"; + // Identifier to be used for participant. + // It's available as LocalParticipant.identity with livekit-client SDK + const participantName = "quickstart-username"; + + const at = new AccessToken( + process.env.LIVEKIT_API_KEY, + process.env.LIVEKIT_API_SECRET, + { + identity: participantName, + // Token to expire after 10 minutes + ttl: "10m", + }, + ); + at.addGrant({ roomJoin: true, room: roomName }); + + return await at.toJwt(); +}; + +const router = Router(); + +router.get("/token", async (req, res) => { + res.send({ + token: await createToken(), + }); +}); + +export default router; diff --git a/apps/dispatch-server/routes/router.ts b/apps/dispatch-server/routes/router.ts new file mode 100644 index 00000000..a549da94 --- /dev/null +++ b/apps/dispatch-server/routes/router.ts @@ -0,0 +1,8 @@ +import { Router } from "express"; +import livekitRouter from "./livekit"; + +const router = Router(); + +router.use("/livekit", livekitRouter); + +export default router; diff --git a/apps/dispatch-server/socket-events/connect-dispatch.ts b/apps/dispatch-server/socket-events/connect-dispatch.ts index 8d1876c7..168be727 100644 --- a/apps/dispatch-server/socket-events/connect-dispatch.ts +++ b/apps/dispatch-server/socket-events/connect-dispatch.ts @@ -11,6 +11,7 @@ export const handle = selectedZone: string; }) => { const userId = socket.data.user.id; // User ID aus dem JWT-Token + console.log("User connected to dispatch server"); await pubClient.json.set(`dispatchers:${socket.id}`, "$", { logoffTime, selectedZone, diff --git a/apps/dispatch/app/(dispatch)/_components/ChangeRufgruppe.tsx b/apps/dispatch/app/(dispatch)/_components/ChangeRufgruppe.tsx deleted file mode 100644 index 876f3d9c..00000000 --- a/apps/dispatch/app/(dispatch)/_components/ChangeRufgruppe.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -export const ChangeRufgruppe = () => { - return ( - <> -
- - - - -
1
-
- -
- - ); -}; diff --git a/apps/dispatch/app/(dispatch)/_components/navbar/Navbar.tsx b/apps/dispatch/app/(dispatch)/_components/navbar/Navbar.tsx index 71a96ed8..126f70a5 100644 --- a/apps/dispatch/app/(dispatch)/_components/navbar/Navbar.tsx +++ b/apps/dispatch/app/(dispatch)/_components/navbar/Navbar.tsx @@ -1,12 +1,12 @@ "use client"; import { ToggleTalkButton } from "../ToggleTalkButton"; -import { ChangeRufgruppe } from "../ChangeRufgruppe"; import { Notifications } from "../Notifications"; import Link from "next/link"; import { Connection } from "../Connection"; import { ThemeSwap } from "./_components/ThemeSwap"; import { useState } from "react"; +import { Audio } from "./_components/Audio"; export default function Navbar() { const [isDark, setIsDark] = useState(false); @@ -31,7 +31,7 @@ export default function Navbar() {
  • - +
  • diff --git a/apps/dispatch/app/(dispatch)/_components/navbar/_components/Audio.tsx b/apps/dispatch/app/(dispatch)/_components/navbar/_components/Audio.tsx new file mode 100644 index 00000000..d46581b7 --- /dev/null +++ b/apps/dispatch/app/(dispatch)/_components/navbar/_components/Audio.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + LocalParticipant, + LocalTrackPublication, + Participant, + RemoteParticipant, + RemoteTrack, + RemoteTrackPublication, + Room, + RoomEvent, + Track, + VideoPresets, +} from "livekit-client"; +import { connectionStore } from "../../../../_store/connectionStore"; + +export const Audio = () => { + const connection = connectionStore(); + const [token, setToken] = useState(""); + const [room, setRoom] = useState(null); + + useEffect(() => { + const fetchToken = async () => { + const response = await fetch( + `${process.env.NEXT_PUBLIC_DISPATCH_SERVER_URL}/livekit/token`, + ); + const data = await response.json(); + setToken(data.token); + }; + + fetchToken(); + }, []); + + useEffect(() => { + const joinRoom = async () => { + if (!connection.isConnected) return; + /* if (!token) return; + if (!process.env.NEXT_PUBLIC_LIVEKIT_URL) + return console.error("NEXT_PUBLIC_LIVEKIT_URL not set"); + console.log("COnnecting to room", { + token, + url: process.env.NEXT_PUBLIC_LIVEKIT_URL, + }); */ + const url = "ws://localhost:7880"; + const token = + "eyJhbGciOiJIUzI1NiJ9.eyJ2aWRlbyI6eyJyb29tSm9pbiI6dHJ1ZSwicm9vbSI6InF1aWNrc3RhcnQtcm9vbSJ9LCJpc3MiOiJBUElBbnNHZHRkWXAySG8iLCJleHAiOjE3NDIxNDk3MzAsIm5iZiI6MCwic3ViIjoicXVpY2tzdGFydC11c2VybmFtZSJ9.MVFDpwvjCF_AXjL9Mg40TFoKukZ4F3vOVB4DI_TZhHM"; + console.log("Connecting to room", { token, url }); + const room = new Room({ + // automatically manage subscribed video quality + adaptiveStream: true, + + // optimize publishing bandwidth and CPU for published tracks + dynacast: true, + + // default capture settings + videoCaptureDefaults: { + resolution: VideoPresets.h720.resolution, + }, + }); + + // pre-warm connection, this can be called as early as your page is loaded + room.prepareConnection(url, token); + + // set up event listeners + room + .on(RoomEvent.TrackSubscribed, handleTrackSubscribed) + .on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed) + .on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakerChange) + .on(RoomEvent.Disconnected, handleDisconnect) + .on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished); + + // connect to room + await room.connect(url, token); + console.log("connected to room", room.name); + + // publish local camera and mic tracks + await room.localParticipant.enableCameraAndMicrophone(); + + function handleTrackSubscribed( + track: RemoteTrack, + publication: RemoteTrackPublication, + participant: RemoteParticipant, + ) { + if ( + track.kind === Track.Kind.Video || + track.kind === Track.Kind.Audio + ) { + // attach it to a new HTMLVideoElement or HTMLAudioElement + const element = track.attach(); + } + } + + function handleTrackUnsubscribed( + track: RemoteTrack, + publication: RemoteTrackPublication, + participant: RemoteParticipant, + ) { + // remove tracks from all attached elements + track.detach(); + } + + function handleLocalTrackUnpublished( + publication: LocalTrackPublication, + participant: LocalParticipant, + ) { + // when local tracks are ended, update UI to remove them from rendering + publication.track?.detach(); + } + + function handleActiveSpeakerChange(speakers: Participant[]) { + // show UI indicators when participant is speaking + } + + function handleDisconnect() { + console.log("disconnected from room"); + } + setRoom(room); + }; + + joinRoom(); + + return () => { + room?.disconnect(); + }; + }, [token, connection.isConnected]); + + return ( + <> +
    + + + + +
    1
    +
    + +
    + + ); +}; diff --git a/apps/dispatch/package.json b/apps/dispatch/package.json index cdcc38e5..de6d72d2 100644 --- a/apps/dispatch/package.json +++ b/apps/dispatch/package.json @@ -11,10 +11,13 @@ "check-types": "tsc --noEmit" }, "dependencies": { + "@livekit/components-react": "^2.8.1", + "@livekit/components-styles": "^1.1.4", "@radix-ui/react-icons": "^1.3.2", "@repo/ui": "*", "@tailwindcss/postcss": "^4.0.14", "leaflet": "^1.9.4", + "livekit-client": "^2.9.7", "next": "^15.1.0", "next-auth": "^4.24.11", "postcss": "^8.5.1", diff --git a/apps/mediasoup-server/.d.ts b/apps/mediasoup-server/.d.ts new file mode 100644 index 00000000..8b3f9cfb --- /dev/null +++ b/apps/mediasoup-server/.d.ts @@ -0,0 +1,8 @@ +declare module "next-auth/jwt" { + interface JWT { + uid: string; + firstname: string; + lastname: string; + email: string; + } +} diff --git a/apps/mediasoup-server/.env.example b/apps/mediasoup-server/.env.example new file mode 100644 index 00000000..3ed5e2cd --- /dev/null +++ b/apps/mediasoup-server/.env.example @@ -0,0 +1,4 @@ +PORT=3002 +REDIS_HOST=localhost +REDIS_PORT=6379 +DISPATCH_APP_TOKEN= \ No newline at end of file diff --git a/apps/mediasoup-server/index.ts b/apps/mediasoup-server/index.ts new file mode 100644 index 00000000..95d3e29c --- /dev/null +++ b/apps/mediasoup-server/index.ts @@ -0,0 +1,31 @@ +import "dotenv/config"; +import express from "express"; +import { createServer } from "http"; +import { Server } from "socket.io"; +import { createAdapter } from "@socket.io/redis-adapter"; +import { jwtMiddleware } from "modules/socketJWTmiddleware"; +import { pubClient, subClient } from "modules/redis"; + +const app = express(); +const server = createServer(app); + +pubClient.keys("dispatchers*").then((keys) => { + if (!keys) return; + keys.forEach(async (key) => { + await pubClient.json.del(key); + }); +}); + +const io = new Server(server, { + adapter: createAdapter(pubClient, subClient), + cors: {}, +}); + +io.use(jwtMiddleware); + +io.on("connection", (socket) => { + console.log("User conencted to mediasoup server"); +}); +server.listen(process.env.PORT, () => { + console.log(`Server running on port ${process.env.PORT}`); +}); diff --git a/apps/mediasoup-server/modules/mediasoup/Channel.ts b/apps/mediasoup-server/modules/mediasoup/Channel.ts new file mode 100644 index 00000000..079b69a5 --- /dev/null +++ b/apps/mediasoup-server/modules/mediasoup/Channel.ts @@ -0,0 +1,317 @@ +import { Worker } from 'mediasoup/node/lib/Worker'; +import { types as MediasoupTypes } from 'mediasoup'; +import logger from 'modules/winston/logger'; +import { Router } from 'mediasoup/node/lib/Router'; +import { DtlsParameters, WebRtcTransport } from 'mediasoup/node/lib/WebRtcTransport'; +import { Socket } from 'socket.io'; +import { UserDocument } from 'models/user'; +import { MediaKind, RtpCapabilities, RtpParameters } from 'mediasoup/node/lib/RtpParameters'; +import { EventEmitter } from 'stream'; +import { ServerTransportParams } from '@common/types/mediasoup'; +import { ConsumerOptions } from 'mediasoup-client/lib/Consumer'; +import { routerOptions, webRtcTransportConfig } from './config'; + +/* * + * Represents a Voice-Channel: all transports, Producers and Consumers are managed using this Class + * Does NOT manages events for when a user joins/ leaves the channel + */ + +export class MediasoupChannel extends EventEmitter { + worker: Worker; + + router: Router | undefined; + + transports: WebRtcTransport[] = []; + + producers: MediasoupTypes.Producer[] = []; + + consumers: MediasoupTypes.Consumer[] = []; + + constructor(worker: Worker) { + super(); + this.worker = worker; + this.init(); + } + + async init() { + this.router = await this.worker.createRouter(/* routerOptions */ routerOptions); + } + + handleSocket(socket: Socket, user: UserDocument) { + if (!this.router || !this.worker) { + logger.warn('Rejected user connection: channel not yet initialized', { system: 'mediasoup' }); + return socket.emit('error-message', { error: 'channel not yet initialized' }); + } + if (this.producers.length >= Number(process.env.MAX_VOICE_CONNECTIONS)) { + logger.warn('Rejected user connection: to many connections', { system: 'mediasoup' }); + return socket.emit('error-message', { error: 'to many connections', system: 'mediasoup' }); + } + socket.emit('sfu-ready'); + + socket.on('get-rtp-capabilities', (callback) => { + const { rtpCapabilities } = this.router!; + + callback(rtpCapabilities); + }); + + socket.on('create-webrtc-transport', async (callback) => { + try { + const transport = await this.createWebRtcTransport(); + this.transports.push(transport); + + // how to delete unused transports which never connected to a client? + // For now: + socket.once('disconnect', () => { + transport.close(); + this.transports = this.transports.filter((t) => t.id !== transport.id); + }); + + // send the parameters for the created transport back to the client + // https://mediasoup.org/documentation/v3/mediasoup-client/api/#TransportOptions + callback({ + id: transport.id, + iceParameters: transport.iceParameters, + iceCandidates: transport.iceCandidates, + dtlsParameters: transport.dtlsParameters + } as ServerTransportParams); + } catch (err) { + const error = err as Error; + callback({ + error: error.message + }); + } + }); + + socket.on( + 'transport-connect', + async ({ dtlsParameters, transportId }: { dtlsParameters: DtlsParameters; transportId: string }) => { + try { + const transport = this.transports.find((t) => t.id === transportId); + + transport?.on('@close', () => { + this.transports = this.transports.filter((t) => t.id !== transportId); + }); + + if (!transport) return; + await transport.connect({ dtlsParameters }); + } catch (error) { + logger.warn('cannot connect transport', { service: 'mediasoup' }); + socket.emit('error-message', { error: (error as Error).message }); + } + } + ); + + socket.on( + 'transport-produce', + async ( + { + kind, + rtpParameters, + transportId + }: { kind: MediaKind; rtpParameters: RtpParameters; transportId: string }, + callback + ) => { + try { + const transport = this.transports.find((t) => t.id === transportId); + if (!transport) throw Error('transport not found'); + const producer = await transport.produce({ + kind, + rtpParameters, + paused: true + }); + // DEBUG + this.producers.push(producer); + this.emit('new-producer', { producerId: producer.id, user: user.getPublicUser() }); + + producer.appData.user = user.getPublicUser(); + producer.observer.on('pause', () => { + this.emit('producer-paused', { producerId: producer.id }); + }); + + producer.observer.on('resume', () => { + this.emit('producer-resumed', { producerId: producer.id }); + }); + + producer.on('transportclose', () => { + this.producers = this.producers.filter((p) => p.id !== producer.id); + this.emit('producer-closed', { producerId: producer.id }); + producer.close(); + }); + + // Send back to the client the Producer's id + callback({ + id: producer.id + }); + } catch (err) { + logger.warn(`Error while creating Producer on Transport! ${err}`, { service: 'mediasoup' }); + const error = err as Error; + callback({ error: error.message }); + } + } + ); + + socket.on( + 'transport-consume', + async ( + { + rtpCapabilities, + producerId, + transportId + }: { + rtpCapabilities: RtpCapabilities; + producerId: string; + transportId: string; + }, + callback + ) => { + try { + const transport = this.transports.find((t) => t.id === transportId); + if (!transport) { + socket.disconnect(); + throw Error('transport not found'); + } + + // check if the router can consume the specified producer + if ( + this.router!.canConsume({ + producerId, + rtpCapabilities + }) + ) { + // transport can now consume and return a consumer + const producer = this.producers.find((p) => p.id === producerId); + + if (!producer) { + socket.disconnect(); + throw Error(`producer ${producerId} not found`); + } + const consumer = await transport.consume({ + producerId, + rtpCapabilities, + appData: { user, producerId }, + paused: producer.paused // Important: otherwise remote video stays black, no audio + }); + + this.consumers.push(consumer); + + // Cannot detect when consumer is closed dirrectly + // Assuming that: + // 1. when producer is closed, consumer is also closed + // 2. when transport is closed, consumer is also closed + // 2. and when socket is closed, consumer is also closed + consumer.on('transportclose', () => { + consumer.close(); + }); + + consumer.observer.on('close', () => { + this.consumers = this.consumers.filter((c) => c.id !== consumer.id); + }); + + // Bad, because it adds a listener for each consumer + /* socket.once('disconnect', () => { + consumer.close(); + this.consumers = this.consumers.filter((c) => c.id !== consumer.id); + }); */ + + consumer.on('producerclose', () => { + consumer.close(); + }); + + // from the consumer extract the following params + // to send back to the Client + const consumerOptions: ConsumerOptions = { + id: consumer.id, + producerId, + kind: consumer.kind, + rtpParameters: consumer.rtpParameters, + appData: { user } + }; + + // send the parameters to the client + callback({ consumerOptions }); + } else { + logger.warn(`Cannot consume producer with id: ${producerId}`, { service: 'mediasoup' }); + callback({ + params: { + error: true + } + }); + } + } catch (err) { + const error = err as Error; + logger.error(`error while creating consumer ${error}`, { service: 'Mediasoup' }); + callback({ + error + }); + } + } + ); + + socket.on('consumer-resume', async ({ consumerId }: { consumerId: string }, resumeCallback) => { + logger.silly(`Resuming consumer with id ${consumerId}`, { service: 'mediasoup' }); + const consumer = this.consumers.find((c) => c.id === consumerId); + if (!consumer) return resumeCallback({ error: 'consumer not found' }); + await consumer.resume(); + if (typeof resumeCallback === 'function') { + resumeCallback(); + } + return true; + }); + socket.on('consumer-pause', async ({ consumerId }: { consumerId: string }, pauseCallback) => { + logger.silly(`Pausing consumer with id ${consumerId}`, { service: 'mediasoup' }); + const consumer = this.consumers.find((c) => c.id === consumerId); + if (!consumer) return pauseCallback({ error: 'consumer not found' }); + await consumer.pause(); + if (typeof pauseCallback === 'function') { + pauseCallback(); + } + return true; + }); + socket.on( + 'producer-resume', + async ({ producerId, source }: { producerId: string; source: string }, resumeCallback) => { + logger.silly(`Resuming producer with id ${producerId}`, { service: 'mediasoup' }); + + const producer = this.producers.find((p) => p.id === producerId); + if (!producer) return resumeCallback({ error: 'producer not found' }); + if (source === 'admin') { + this.producers.forEach((p) => p.pause()); + this.emit('producer-pausing-forced', { user: user.getPublicUser() }); + } + await producer.resume(); + if (resumeCallback) { + resumeCallback(); + } + return true; + } + ); + socket.on('producer-pause', async ({ producerId }: { producerId: string }, pauseCallback) => { + logger.silly(`Pausing producer with id ${producerId}`, { service: 'mediasoup' }); + const producer = this.producers.find((p) => p.id === producerId); + if (!producer) return pauseCallback({ error: 'producer not found' }); + await producer.pause(); + + if (typeof pauseCallback === 'function') { + pauseCallback(); + } + return true; + }); + + return true; + } + + async createWebRtcTransport(): Promise { + // https://mediasoup.org/documentation/v3/mediasoup/api/#router-createWebRtcTransport + const transport = await this.router!.createWebRtcTransport(webRtcTransportConfig); + transport.on('dtlsstatechange', (dtlsState) => { + // Not reliable + logger.silly('transport dtlsstatechange', { dtlsState, service: 'mediasoup' }); + if (dtlsState === 'closed') { + transport.close(); + this.transports = this.transports.filter((t) => t.id !== transport.id); + } + }); + + return transport; + } +} diff --git a/apps/mediasoup-server/modules/mediasoup/config.ts b/apps/mediasoup-server/modules/mediasoup/config.ts new file mode 100644 index 00000000..452acced --- /dev/null +++ b/apps/mediasoup-server/modules/mediasoup/config.ts @@ -0,0 +1,51 @@ +import os from 'os'; +import { types as mediasoupTypes } from 'mediasoup'; + +export const workerSettings: mediasoupTypes.WorkerSettings = { + rtcMinPort: 2000, + rtcMaxPort: 2300, + logLevel: 'debug', + logTags: ['info', 'ice', 'dtls', 'rtp', 'srtp', 'rtcp', 'message'] +}; + +export const routerOptions: mediasoupTypes.RouterOptions = { + mediaCodecs: [ + { + kind: 'audio', + mimeType: 'audio/opus', + clockRate: 48000, + channels: 2 + }, + { + kind: 'video', + mimeType: 'video/VP8', + clockRate: 90000, + parameters: { + 'x-google-start-bitrate': 1000 + } + } + ] +}; + +export const webRtcTransportConfig: mediasoupTypes.WebRtcTransportOptions = { + // https://mediasoup.org/documentation/v3/mediasoup/api/#WebRtcTransportOptions + listenInfos: [ + { + protocol: 'tcp', + ip: '0.0.0.0', + announcedIp: process.env.MEDIASOUP_ANOUNCE_IP // public ip + }, + { + protocol: 'udp', + ip: '0.0.0.0', + announcedIp: process.env.MEDIASOUP_ANOUNCE_IP // public ip + } + ], + enableUdp: true, + enableTcp: true, + preferUdp: true +}; + +export default { + numWorkers: Object.keys(os.cpus()).length +}; diff --git a/apps/mediasoup-server/modules/mediasoup/worker.ts b/apps/mediasoup-server/modules/mediasoup/worker.ts new file mode 100644 index 00000000..8b7419c4 --- /dev/null +++ b/apps/mediasoup-server/modules/mediasoup/worker.ts @@ -0,0 +1,21 @@ +import { createWorker as mediasoupCreateWorker } from 'mediasoup'; +import logger from 'modules/winston/logger'; +import { workerSettings } from './config'; + +/** + * * For now, each channel uses its own worker + * ! Do not create more Workers than the number of CPU-Cores + */ + +export const createWorker = async () => { + const worker = await mediasoupCreateWorker(workerSettings); + logger.info(`Mediasoup worker created`, { service: 'mediasoup' }); + + worker.on('died', (error) => { + // This implies something serious happened, so kill the application + logger.error(`Mediasoup worker crashed! ${error}`, { error, service: 'mediasoup' }); + setTimeout(() => process.exit(1), 2000); // exit in 2 seconds + }); + + return worker; +}; diff --git a/apps/mediasoup-server/modules/redis.ts b/apps/mediasoup-server/modules/redis.ts new file mode 100644 index 00000000..02151d4a --- /dev/null +++ b/apps/mediasoup-server/modules/redis.ts @@ -0,0 +1,8 @@ +import { createClient } from "redis"; + +export const pubClient = createClient(); +export const subClient = pubClient.duplicate(); + +Promise.all([pubClient.connect(), subClient.connect()]).then(() => { + console.log("Redis connected"); +}); diff --git a/apps/mediasoup-server/modules/socketJWTmiddleware.ts b/apps/mediasoup-server/modules/socketJWTmiddleware.ts new file mode 100644 index 00000000..6ec74573 --- /dev/null +++ b/apps/mediasoup-server/modules/socketJWTmiddleware.ts @@ -0,0 +1,28 @@ +import { ExtendedError, Server, Socket } from "socket.io"; +import { prisma } from "@repo/db"; +if (!process.env.DISPATCH_APP_TOKEN) + throw new Error("DISPATCH_APP_TOKEN is not defined"); + +export const jwtMiddleware = async ( + socket: Socket, + next: (err?: ExtendedError) => void, +) => { + try { + const { uid } = socket.handshake.auth; + if (!uid) return new Error("Authentication error"); + /* const token = socket.handshake.auth?.token; + if (!token) return new Error("Authentication error"); + const decoded = jwt.verify(token, process.env.DISPATCH_APP_TOKEN!); */ + // socket.data.userId = decoded.; // User ID lokal speichern + const user = await prisma.user.findUniqueOrThrow({ + where: { id: uid }, + }); + + socket.data.user = user; + + next(); + } catch (err) { + console.error(err); + next(new Error("Authentication error")); + } +}; diff --git a/apps/mediasoup-server/nodemon.json b/apps/mediasoup-server/nodemon.json new file mode 100644 index 00000000..8bb27568 --- /dev/null +++ b/apps/mediasoup-server/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["."], + "ext": "ts", + "exec": "tsx index.ts" +} \ No newline at end of file diff --git a/apps/mediasoup-server/package.json b/apps/mediasoup-server/package.json new file mode 100644 index 00000000..b5a32169 --- /dev/null +++ b/apps/mediasoup-server/package.json @@ -0,0 +1,33 @@ +{ + "name": "mediasoup-server", + "exports": { + "helpers": "./helper" + }, + "scripts": { + "dev": "nodemon", + "build": "tsc" + }, + "devDependencies": { + "@repo/db": "*", + "@repo/typescript-config": "*", + "@types/express": "^5.0.0", + "@types/node": "^22.13.5", + "@types/nodemailer": "^6.4.17", + "concurrently": "^9.1.2", + "typescript": "latest" + }, + "dependencies": { + "@react-email/components": "^0.0.33", + "@redis/json": "^1.0.7", + "@socket.io/redis-adapter": "^8.3.0", + "axios": "^1.7.9", + "cron": "^4.1.0", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "nodemailer": "^6.10.0", + "react": "^19.0.0", + "redis": "^4.7.0", + "socket.io": "^4.8.1" + } +} diff --git a/apps/mediasoup-server/socket-events/sfu.ts b/apps/mediasoup-server/socket-events/sfu.ts new file mode 100644 index 00000000..7cf78f4b --- /dev/null +++ b/apps/mediasoup-server/socket-events/sfu.ts @@ -0,0 +1,158 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Worker } from 'mediasoup/node/lib/types'; +import { UserDocument } from 'models/user'; +import { MediasoupChannel } from 'modules/mediasoup/Channel'; +import { createWorker } from 'modules/mediasoup/worker'; +import { Socket } from 'socket.io'; +import { User } from '@common/types/user'; +import { EventEmitter } from 'stream'; +import { setMemberNickname } from 'modules/bot/bot'; +import PilotController from './pilot'; + +interface SfuClient { + socket: Socket; + channelId: string; + user: UserDocument; +} + +interface ISfuController { + worker: Worker[]; + observer: EventEmitter; + clients: Map; + channel: Map; + init: () => void; + handle: (channelId: string, socket: Socket, user: UserDocument) => void; + disconnectUser: (userId: string) => void; +} + +const SfuController: ISfuController = { + clients: new Map(), + channel: new Map(), + observer: new EventEmitter(), + worker: [], + disconnectUser: async (userId: string) => { + SfuController.clients.forEach((client) => { + if (client.user._id.toString() === userId) { + client.socket.emit('error-message', { error: 'DISCONNECTED_BY_ADMIN' }); + + client.socket.disconnect(); + SfuController.clients.delete(client.socket.id); + } + }); + }, + init: async () => { + // Setup Worker and Channel + // Scalable! + SfuController.worker = [ + await createWorker(), + await createWorker(), + await createWorker(), + await createWorker(), + await createWorker() + ]; + SfuController.channel.set('1', new MediasoupChannel(SfuController.worker[0])); // LST_VAR_RD_01 + SfuController.channel.set('2', new MediasoupChannel(SfuController.worker[1])); // LST_VAR_RD_02 + SfuController.channel.set('3', new MediasoupChannel(SfuController.worker[2])); // LST_VAR_RD_03 + SfuController.channel.set('4', new MediasoupChannel(SfuController.worker[3])); // LST_VAR_RD_04 + SfuController.channel.set('x', new MediasoupChannel(SfuController.worker[4])); // LST_VAR_RESERVE + + // setup listends for new connections, producer-close event is handled by Channel + SfuController.channel.forEach((channel, channelId) => { + channel.on('producer-pausing-forced', ({ user }: { user: User.PublicUser }) => { + SfuController.clients.forEach((client) => { + if (client.channelId === channelId && client.user._id.toString() !== user.id) { + client.socket.emit('producer-pausing-forced', { user }); + } + }); + }); + channel.on('new-producer', ({ producerId, user }) => { + SfuController.clients.forEach((client) => { + if (client.channelId === channelId) { + client.socket.emit('new-producer', { producerId, user }); + } + }); + }); + channel.on('producer-closed', ({ producerId }) => { + SfuController.clients.forEach((client) => { + if (client.channelId === channelId) { + client.socket.emit('producer-closed', { producerId }); + } + }); + }); + channel.on('producer-paused', ({ producerId }) => { + SfuController.clients.forEach((client) => { + if (client.channelId === channelId) { + client.socket.emit('producer-paused', { producerId }); + } + }); + }); + channel.on('producer-resumed', ({ producerId }) => { + SfuController.clients.forEach((client) => { + if (client.channelId === channelId) { + client.socket.emit('producer-resumed', { producerId }); + } + }); + }); + }); + }, + handle: (channelId, socket, user) => { + const channel = SfuController.channel.get(channelId); + if (!channel) { + socket.emit('error-message', { error: 'invalid channel id' }); + return; + } + + // check for double connections + if ( + Array.from(SfuController.clients.values()).find( + (client) => client.user._id.toString() === user._id.toString() + ) && + process.env.ALLOW_DOUBLE_CONNECTION === 'false' + ) { + socket.emit('error-message', { error: 'DOUBLE_CONNECTION' }); + return; + } + + SfuController.clients.set(socket.id, { socket, channelId, user }); + + // Update Discord username for dispatcher + SfuController.observer.emit('channel-changed', channelId, user); + + const userInPilot = PilotController.clients.get(user._id.toString()); + + if (userInPilot) { + userInPilot.voiceChannel = channelId; + PilotController.clients.set(user._id.toString(), userInPilot); + PilotController.observer.emit('stations-changed'); + } + + channel.handleSocket(socket, user); + + socket.on('get-producers', (getPcb) => { + getPcb(channel.producers.map((p) => ({ producerId: p.id, user: p.appData.user, paused: p.paused }))); + }); + socket.on('disconnect', () => { + socket.removeAllListeners(); + SfuController.clients.delete(socket.id); + const userInPilotDisconnect = PilotController.clients.get(user._id.toString()); + + if (userInPilotDisconnect) { + userInPilotDisconnect.voiceChannel = undefined; + PilotController.clients.set(user._id.toString(), userInPilotDisconnect); + PilotController.observer.emit('stations-changed'); + } + }); + socket.on('set-should-transmit', (eventData: { shouldTransmit: boolean; source: string }) => { + SfuController.clients.forEach((client) => { + if (client.user._id.toString() === user._id.toString()) { + client.socket.emit('set-should-transmit', eventData); + } + }); + }); + } +}; + +SfuController.init(); + +export default SfuController; diff --git a/apps/mediasoup-server/tsconfig.json b/apps/mediasoup-server/tsconfig.json new file mode 100644 index 00000000..ad90dfe0 --- /dev/null +++ b/apps/mediasoup-server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "allowImportingTsExtensions": false, + "baseUrl": ".", + "jsx": "react" + }, + "include": ["**/*.ts", "./index.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ea7338d3..2f585a25 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -64,6 +64,21 @@ services: - moodle_data:/bitnami/moodle - moodle_moodledata:/bitnami/moodledata # Für den Zugriff auf den Host + livekit-server: + image: livekit/livekit-server + container_name: livekit_server + restart: unless-stopped + ports: + - "7880:7880" + - "7881:7881" + - "7882:7882/udp" + volumes: + - "./livekit.yaml:/livekit.yaml" + command: + - "--config" + - "/livekit.yaml" + - "--node-ip=127.0.0.1" + volumes: postgres-data: moodle_data: diff --git a/livekit.yaml b/livekit.yaml new file mode 100644 index 00000000..2e69fa88 --- /dev/null +++ b/livekit.yaml @@ -0,0 +1,13 @@ +port: 7880 +rtc: + udp_port: 7882 + tcp_port: 7881 + use_external_ip: false + enable_loopback_candidate: false + ice_servers: + - urls: ["stun:stun.l.google.com:19302"] +keys: + APIAnsGdtdYp2Ho: tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC +logging: + json: false + level: info diff --git a/package-lock.json b/package-lock.json index ac4479a9..a65d94d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,10 +21,13 @@ "apps/dispatch": { "version": "0.1.0", "dependencies": { + "@livekit/components-react": "^2.8.1", + "@livekit/components-styles": "^1.1.4", "@radix-ui/react-icons": "^1.3.2", "@repo/ui": "*", "@tailwindcss/postcss": "^4.0.14", "leaflet": "^1.9.4", + "livekit-client": "^2.9.7", "next": "^15.1.0", "next-auth": "^4.24.11", "postcss": "^8.5.1", @@ -52,15 +55,16 @@ "@redis/json": "^1.0.7", "@socket.io/redis-adapter": "^8.3.0", "axios": "^1.7.9", + "cors": "^2.8.5", "cron": "^4.1.0", "dotenv": "^16.4.7", "express": "^4.21.2", "jsonwebtoken": "^9.0.2", + "livekit-server-sdk": "^2.10.2", "nodemailer": "^6.10.0", "react": "^19.0.0", "redis": "^4.7.0", - "socket.io": "^4.8.1", - "socket.io-redis": "^6.1.1" + "socket.io": "^4.8.1" }, "devDependencies": { "@repo/db": "*", @@ -68,7 +72,6 @@ "@types/express": "^5.0.0", "@types/node": "^22.13.5", "@types/nodemailer": "^6.4.17", - "@types/socket.io-redis": "^3.0.0", "concurrently": "^9.1.2", "typescript": "latest" } @@ -158,6 +161,31 @@ "typescript": "latest" } }, + "apps/mediasoup-server": { + "dependencies": { + "@react-email/components": "^0.0.33", + "@redis/json": "^1.0.7", + "@socket.io/redis-adapter": "^8.3.0", + "axios": "^1.7.9", + "cron": "^4.1.0", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "nodemailer": "^6.10.0", + "react": "^19.0.0", + "redis": "^4.7.0", + "socket.io": "^4.8.1" + }, + "devDependencies": { + "@repo/db": "*", + "@repo/typescript-config": "*", + "@types/express": "^5.0.0", + "@types/node": "^22.13.5", + "@types/nodemailer": "^6.4.17", + "concurrently": "^9.1.2", + "typescript": "latest" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -451,6 +479,12 @@ "node": ">=6.9.0" } }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", + "integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1734,6 +1768,108 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@livekit/components-core": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.1.tgz", + "integrity": "sha512-R7qWoVzPckOYxEHZgP3Kp8u+amu+isnTptgoZV7+bpmLRBHI7mWnaD+0uDWlyIMjI1pBbK3wHg0ILKa5UytI+A==", + "license": "Apache-2.0", + "dependencies": { + "@floating-ui/dom": "1.6.11", + "loglevel": "1.9.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "livekit-client": "^2.8.1", + "tslib": "^2.6.2" + } + }, + "node_modules/@livekit/components-core/node_modules/@floating-ui/dom": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.11.tgz", + "integrity": "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@livekit/components-core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@livekit/components-react": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.8.1.tgz", + "integrity": "sha512-XpuDu7iDMcN4pkV8CYNzHf9hLNdYOeEtbmCr7Zesy6Au3BxUl4aS1Ajmg0b75Rx7zTlkyCJt9Lm4VrEqbJCI6Q==", + "license": "Apache-2.0", + "dependencies": { + "@livekit/components-core": "0.12.1", + "clsx": "2.1.1", + "usehooks-ts": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@livekit/krisp-noise-filter": "^0.2.12", + "livekit-client": "^2.8.1", + "react": ">=18", + "react-dom": ">=18", + "tslib": "^2.6.2" + }, + "peerDependenciesMeta": { + "@livekit/krisp-noise-filter": { + "optional": true + } + } + }, + "node_modules/@livekit/components-react/node_modules/usehooks-ts": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.0.tgz", + "integrity": "sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/@livekit/components-styles": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@livekit/components-styles/-/components-styles-1.1.4.tgz", + "integrity": "sha512-QCupn7tQ/dy/WZclrfsgtDe8peiGYS6Ied1IGkKOysaXo04l90t62SIUTKyxgd0dNDhUDC0p34qCggGZs/44lQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@livekit/mutex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", + "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", + "license": "Apache-2.0" + }, + "node_modules/@livekit/protocol": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.34.0.tgz", + "integrity": "sha512-bU7pCLAMRVTVZb1KSxA46q55bhOc4iATrY/gccy2/oX1D57tiZEI+8wGRWHeDwBb0UwnABu6JXzC4tTFkdsaOg==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, "node_modules/@next-auth/prisma-adapter": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@next-auth/prisma-adapter/-/prisma-adapter-1.0.7.tgz", @@ -3233,17 +3369,6 @@ "@types/send": "*" } }, - "node_modules/@types/socket.io-redis": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/socket.io-redis/-/socket.io-redis-3.0.0.tgz", - "integrity": "sha512-AFINYd5w7LwUqUDZAfmv4AEhXOJ0YsXCQf11RRDok+Zq7cCyhTMgFAbWSwUScP/JEYD4KF6+4O4SiussjrKbmQ==", - "deprecated": "This is a stub types definition. socket.io-redis provides its own type definitions, so you do not need this installed.", - "dev": true, - "license": "MIT", - "dependencies": { - "socket.io-redis": "*" - } - }, "node_modules/@types/through": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", @@ -4543,6 +4668,48 @@ "upper-case": "^1.1.1" } }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz", + "integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==", + "license": "MIT", + "dependencies": { + "camelcase": "^8.0.0", + "map-obj": "5.0.0", + "quick-lru": "^6.1.1", + "type-fest": "^4.3.2" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/type-fest": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.37.0.tgz", + "integrity": "sha512-S/5/0kFftkq27FPNye0XM1e2NsnoD/3FS+pBmbjmmtLT6I+i344KoOf7pvXreaFsDamWeaJX55nczA1m5PsBDg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001704", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001704.tgz", @@ -5449,15 +5616,6 @@ "node": ">=0.4.0" } }, - "node_modules/denque": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", - "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6700,7 +6858,6 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -9408,6 +9565,60 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/livekit-client": { + "version": "2.9.7", + "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.9.7.tgz", + "integrity": "sha512-a+Y76HE5k7IaFOpDnr14ON+VOAgh7cCjuBq8Loq5p5xHZzw+/cQyX/xPsMLU4lloKO5zGf45YZJYt/Egk1Xg+g==", + "license": "Apache-2.0", + "dependencies": { + "@livekit/mutex": "1.1.1", + "@livekit/protocol": "1.34.0", + "events": "^3.3.0", + "loglevel": "^1.9.2", + "sdp-transform": "^2.15.0", + "ts-debounce": "^4.0.0", + "tslib": "2.8.1", + "typed-emitter": "^2.1.0", + "webrtc-adapter": "^9.0.1" + } + }, + "node_modules/livekit-client/node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/livekit-server-sdk": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.10.2.tgz", + "integrity": "sha512-XDoHvLY9a6DXM7Iit7XdNp1M9OK/idWHuqZnKAoirBbPmaFmlAVKeQGQIZTG7UrktgoRAPvu3vv0UdC6Ds80Ng==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.7.2", + "@livekit/protocol": "^1.32.1", + "camelcase-keys": "^9.0.0", + "jose": "^5.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/livekit-server-sdk/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -9440,6 +9651,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -9510,6 +9727,19 @@ "node": ">=8" } }, + "node_modules/loglevel": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", + "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -9583,6 +9813,18 @@ "dev": true, "license": "ISC" }, + "node_modules/map-obj": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz", + "integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -9917,6 +10159,10 @@ "node": ">= 0.6" } }, + "node_modules/mediasoup-server": { + "resolved": "apps/mediasoup-server", + "link": true + }, "node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", @@ -14427,6 +14673,18 @@ ], "license": "MIT" }, + "node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -14693,33 +14951,6 @@ "@redis/time-series": "1.1.0" } }, - "node_modules/redis-commands": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", - "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==", - "license": "MIT" - }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "dependencies": { - "redis-errors": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -15244,7 +15475,7 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -15390,6 +15621,21 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/sdp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz", + "integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==", + "license": "MIT" + }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, "node_modules/selderee": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", @@ -15863,76 +16109,6 @@ } } }, - "node_modules/socket.io-redis": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/socket.io-redis/-/socket.io-redis-6.1.1.tgz", - "integrity": "sha512-jeaXe3TGKC20GMSlPHEdwTUIWUpay/L7m5+S9TQcOf22p9Llx44/RkpJV08+buXTZ8E+aivOotj2RdeFJJWJJQ==", - "deprecated": "This package has been renamed to '@socket.io/redis-adapter', please see the migration guide here: https://socket.io/docs/v4/redis-adapter/#migrating-from-socketio-redis", - "license": "MIT", - "dependencies": { - "debug": "~4.3.1", - "notepack.io": "~2.2.0", - "redis": "^3.0.0", - "socket.io-adapter": "~2.2.0", - "uid2": "0.0.3" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io-redis/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io-redis/node_modules/notepack.io": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-2.2.0.tgz", - "integrity": "sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw==", - "license": "MIT" - }, - "node_modules/socket.io-redis/node_modules/redis": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", - "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", - "license": "MIT", - "dependencies": { - "denque": "^1.5.0", - "redis-commands": "^1.7.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-redis" - } - }, - "node_modules/socket.io-redis/node_modules/socket.io-adapter": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.2.0.tgz", - "integrity": "sha512-rG49L+FwaVEwuAdeBRq49M97YI3ElVabJPzvHT9S6a2CWhDKnjSFasvwAwSYPRhQzfn4NtDIbCaGYgOCOU/rlg==", - "license": "MIT" - }, - "node_modules/socket.io-redis/node_modules/uid2": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", - "integrity": "sha512-5gSP1liv10Gjp8cMEnFd6shzkL/D6W1uhXSFNCxDC+YI8+L8wkCYCbJ7n77Ezb4wE/xzMogecE+DtamEe9PZjg==" - }, "node_modules/socket.io/node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -16589,6 +16765,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-debounce": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz", + "integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==", + "license": "MIT" + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -16884,6 +17066,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, "node_modules/typescript": { "version": "5.8.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", @@ -17376,6 +17567,19 @@ "node": ">=4.0" } }, + "node_modules/webrtc-adapter": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.1.tgz", + "integrity": "sha512-1AQO+d4ElfVSXyzNVTOewgGT/tAomwwztX/6e3totvyyzXPvXIIuUUjAmyZGbKBKbZOXauuJooZm3g6IuFuiNQ==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/test/livekit.yaml b/test/livekit.yaml new file mode 100644 index 00000000..7c78d3f4 --- /dev/null +++ b/test/livekit.yaml @@ -0,0 +1,11 @@ +port: 7880 +rtc: + udp_port: 7882 + tcp_port: 7881 + use_external_ip: false + enable_loopback_candidate: false +keys: + APIAnsGdtdYp2Ho: tdPjVsYUx8ddC7K9NvdmVAeLRF9GeADD6Fedm1x63fWC +logging: + json: false + level: info