From 4862f736124cb26fdc154299b855a830e1248452 Mon Sep 17 00:00:00 2001
From: PxlLoewe <72106766+PxlLoewe@users.noreply.github.com>
Date: Sun, 16 Mar 2025 13:56:18 -0700
Subject: [PATCH] livekit
---
apps/dispatch-server/index.ts | 7 +-
apps/dispatch-server/modules/redis.ts | 9 +
apps/dispatch-server/package.json | 2 +
apps/dispatch-server/routes/livekit.ts | 38 ++
apps/dispatch-server/routes/router.ts | 8 +
.../socket-events/connect-dispatch.ts | 1 +
.../_components/ChangeRufgruppe.tsx | 35 --
.../(dispatch)/_components/navbar/Navbar.tsx | 4 +-
.../_components/navbar/_components/Audio.tsx | 159 +++++++
apps/dispatch/package.json | 3 +
apps/mediasoup-server/.d.ts | 8 +
apps/mediasoup-server/.env.example | 4 +
apps/mediasoup-server/index.ts | 31 ++
.../modules/mediasoup/Channel.ts | 317 +++++++++++++
.../modules/mediasoup/config.ts | 51 ++
.../modules/mediasoup/worker.ts | 21 +
apps/mediasoup-server/modules/redis.ts | 8 +
.../modules/socketJWTmiddleware.ts | 28 ++
apps/mediasoup-server/nodemon.json | 5 +
apps/mediasoup-server/package.json | 33 ++
apps/mediasoup-server/socket-events/sfu.ts | 158 ++++++
apps/mediasoup-server/tsconfig.json | 11 +
docker-compose.dev.yml | 15 +
livekit.yaml | 13 +
package-lock.json | 448 +++++++++++++-----
test/livekit.yaml | 11 +
26 files changed, 1268 insertions(+), 160 deletions(-)
create mode 100644 apps/dispatch-server/routes/livekit.ts
create mode 100644 apps/dispatch-server/routes/router.ts
delete mode 100644 apps/dispatch/app/(dispatch)/_components/ChangeRufgruppe.tsx
create mode 100644 apps/dispatch/app/(dispatch)/_components/navbar/_components/Audio.tsx
create mode 100644 apps/mediasoup-server/.d.ts
create mode 100644 apps/mediasoup-server/.env.example
create mode 100644 apps/mediasoup-server/index.ts
create mode 100644 apps/mediasoup-server/modules/mediasoup/Channel.ts
create mode 100644 apps/mediasoup-server/modules/mediasoup/config.ts
create mode 100644 apps/mediasoup-server/modules/mediasoup/worker.ts
create mode 100644 apps/mediasoup-server/modules/redis.ts
create mode 100644 apps/mediasoup-server/modules/socketJWTmiddleware.ts
create mode 100644 apps/mediasoup-server/nodemon.json
create mode 100644 apps/mediasoup-server/package.json
create mode 100644 apps/mediasoup-server/socket-events/sfu.ts
create mode 100644 apps/mediasoup-server/tsconfig.json
create mode 100644 livekit.yaml
create mode 100644 test/livekit.yaml
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