diff --git a/.env.example b/.env.example index 937b7d3..f961fa0 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,17 @@ # 1. Copy this file and rename it to .env.local # 2. Update the enviroment variables below. -# API key and secret. If you use LiveKit Cloud this can be generated via the cloud dashboard. -LIVEKIT_API_KEY=devkey -LIVEKIT_API_SECRET=secret +# REQUIRED SETTINGS +# ################# +# If you are using LiveKit Cloud, the API key and secret can be generated from the Cloud Dashboard. +LIVEKIT_API_KEY= +LIVEKIT_API_SECRET= +# URL pointing to the LiveKit server. (example: `wss://my-livekit-project.livekit.cloud`) +LIVEKIT_URL= -# URL pointing to the LiveKit server. -LIVEKIT_URL=wss://my-livekit-project.livekit.cloud +# OPTIONAL SETTINGS +# ################# # Recording # S3_KEY_ID= # S3_KEY_SECRET= @@ -16,11 +20,9 @@ LIVEKIT_URL=wss://my-livekit-project.livekit.cloud # S3_REGION= # PUBLIC -NEXT_PUBLIC_LK_TOKEN_ENDPOINT=/api/token -#NEXT_PUBLIC_LK_RECORD_ENDPOINT=/api/record - # Uncomment settings menu when using a LiveKit Cloud, it'll enable Krisp noise filters. # NEXT_PUBLIC_SHOW_SETTINGS_MENU=true +# NEXT_PUBLIC_LK_RECORD_ENDPOINT=/api/record # Optional, to pipe logs to datadog # NEXT_PUBLIC_DATADOG_CLIENT_TOKEN=client-token diff --git a/app/api/connection-details/route.ts b/app/api/connection-details/route.ts new file mode 100644 index 0000000..8e33791 --- /dev/null +++ b/app/api/connection-details/route.ts @@ -0,0 +1,61 @@ +import { ConnectionDetails } from '@/lib/types'; +import { randomUUID } from 'crypto'; +import { AccessToken, AccessTokenOptions, VideoGrant } from 'livekit-server-sdk'; +import { NextRequest, NextResponse } from 'next/server'; + +const API_KEY = process.env.LIVEKIT_API_KEY; +const API_SECRET = process.env.LIVEKIT_API_SECRET; +const LIVEKIT_URL = process.env.LIVEKIT_URL; + +export async function GET(request: NextRequest) { + try { + // Parse query parameters + const roomName = request.nextUrl.searchParams.get('roomName'); + const participantName = request.nextUrl.searchParams.get('participantName'); + const metadata = request.nextUrl.searchParams.get('metadata') ?? ''; + + if (typeof roomName !== 'string') { + return new NextResponse('Missing required query parameter: roomName', { status: 400 }); + } + if (participantName === null) { + return new NextResponse('Missing required query parameter: participantName', { status: 400 }); + } + + // Generate participant token + const participantToken = await createParticipantToken( + { + identity: `${participantName}__${randomUUID()}`, + name: participantName, + metadata, + }, + roomName, + ); + + // Return connection details + const data: ConnectionDetails = { + serverUrl: LIVEKIT_URL!, + roomName: roomName, + participantToken: participantToken, + participantName: participantName, + }; + return NextResponse.json(data); + } catch (error) { + if (error instanceof Error) { + return new NextResponse(error.message, { status: 500 }); + } + } +} + +function createParticipantToken(userInfo: AccessTokenOptions, roomName: string) { + const at = new AccessToken(API_KEY, API_SECRET, userInfo); + at.ttl = '5m'; + const grant: VideoGrant = { + room: roomName, + roomJoin: true, + canPublish: true, + canPublishData: true, + canSubscribe: true, + }; + at.addGrant(grant); + return at.toJwt(); +} diff --git a/app/api/record/start/route.ts b/app/api/record/start/route.ts index a07f381..dfe88f5 100644 --- a/app/api/record/start/route.ts +++ b/app/api/record/start/route.ts @@ -1,11 +1,9 @@ import { EgressClient, EncodedFileOutput, S3Upload } from 'livekit-server-sdk'; -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; -export async function GET(req: Request) { +export async function GET(req: NextRequest) { try { - const url = new URL(req.url); - const searchParams = url.searchParams; - const roomName = searchParams.get('roomName'); + const roomName = req.nextUrl.searchParams.get('roomName'); /** * CAUTION: @@ -14,7 +12,7 @@ export async function GET(req: Request) { * DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS */ - if (typeof roomName !== 'string') { + if (roomName === null) { return new NextResponse('Missing roomName parameter', { status: 403 }); } diff --git a/app/api/record/stop/route.ts b/app/api/record/stop/route.ts index bfbfc49..e2630ac 100644 --- a/app/api/record/stop/route.ts +++ b/app/api/record/stop/route.ts @@ -1,11 +1,9 @@ import { EgressClient } from 'livekit-server-sdk'; -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; -export async function GET(req: Request) { +export async function GET(req: NextRequest) { try { - const url = new URL(req.url); - const searchParams = url.searchParams; - const roomName = searchParams.get('roomName'); + const roomName = req.nextUrl.searchParams.get('roomName'); /** * CAUTION: @@ -14,7 +12,7 @@ export async function GET(req: Request) { * DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS */ - if (typeof roomName !== 'string') { + if (roomName === null) { return new NextResponse('Missing roomName parameter', { status: 403 }); } diff --git a/app/api/token/route.ts b/app/api/token/route.ts deleted file mode 100644 index 3503839..0000000 --- a/app/api/token/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { AccessToken } from 'livekit-server-sdk'; -import type { AccessTokenOptions, VideoGrant } from 'livekit-server-sdk'; -import { TokenResult } from '@/lib/types'; -import { NextResponse } from 'next/server'; - -const apiKey = process.env.LIVEKIT_API_KEY; -const apiSecret = process.env.LIVEKIT_API_SECRET; - -const createToken = (userInfo: AccessTokenOptions, grant: VideoGrant) => { - const at = new AccessToken(apiKey, apiSecret, userInfo); - at.ttl = '5m'; - at.addGrant(grant); - return at.toJwt(); -}; - -const roomPattern = /\w{4}\-\w{4}/; - -export async function GET(req: Request) { - try { - const url = new URL(req.url); - const searchParams = url.searchParams; - const roomName = searchParams.get('roomName'); - const identity = searchParams.get('identity'); - const name = searchParams.get('name'); - const metadata = searchParams.get('metadata') ?? ''; - - if (typeof identity !== 'string' || typeof roomName !== 'string') { - return new NextResponse('Forbidden', { status: 401 }); - } - if (name === null) { - return new NextResponse('Provide a name.', { status: 400 }); - } - if (Array.isArray(name)) { - return new NextResponse('Provide only one room name.', { status: 400 }); - } - if (Array.isArray(metadata)) { - return new NextResponse('Provide only one metadata string.', { status: 400 }); - } - - // enforce room name to be xxxx-xxxx - // this is simple & naive way to prevent user from guessing room names - // please use your own authentication mechanisms in your own app - if (!roomName.match(roomPattern)) { - return new NextResponse('Invalid room name format.', { status: 400 }); - } - - const grant: VideoGrant = { - room: roomName, - roomJoin: true, - canPublish: true, - canPublishData: true, - canSubscribe: true, - }; - - const token = await createToken({ identity, name, metadata }, grant); - const result: TokenResult = { - identity, - accessToken: token, - }; - - return NextResponse.json(result); - } catch (error) { - if (error instanceof Error) { - return new NextResponse(error.message, { status: 500 }); - } - } -} diff --git a/app/api/url/route.ts b/app/api/url/route.ts index c6c9426..232a348 100644 --- a/app/api/url/route.ts +++ b/app/api/url/route.ts @@ -1,15 +1,9 @@ -import { getLiveKitURL } from '../../../lib/server-utils'; -import { NextResponse } from 'next/server'; +import { getLiveKitURL } from '@/lib/server-utils'; +import { NextRequest, NextResponse } from 'next/server'; -export async function GET(req: Request) { +export async function GET(req: NextRequest) { try { - const url = new URL(req.url); - const searchParams = url.searchParams; - const region = searchParams.get('region'); - - if (Array.isArray(region)) { - throw Error('provide max one region string'); - } + const region = req.nextUrl.searchParams.get('region'); const livekitUrl = getLiveKitURL(region); return NextResponse.json({ url: livekitUrl }); } catch (error) { diff --git a/app/rooms/[roomName]/PageClientImpl.tsx b/app/rooms/[roomName]/PageClientImpl.tsx new file mode 100644 index 0000000..1bf8e0a --- /dev/null +++ b/app/rooms/[roomName]/PageClientImpl.tsx @@ -0,0 +1,176 @@ +'use client'; + +import { decodePassphrase } from '@/lib/client-utils'; +import { DebugMode } from '@/lib/Debug'; +import { RecordingIndicator } from '@/lib/RecordingIndicator'; +import { SettingsMenu } from '@/lib/SettingsMenu'; +import { ConnectionDetails } from '@/lib/types'; +import { + formatChatMessageLinks, + LiveKitRoom, + LocalUserChoices, + PreJoin, + VideoConference, +} from '@livekit/components-react'; +import { + ExternalE2EEKeyProvider, + RoomOptions, + VideoCodec, + VideoPresets, + Room, + DeviceUnsupportedError, + RoomConnectOptions, +} from 'livekit-client'; +import { useRouter } from 'next/navigation'; +import React from 'react'; + +const CONN_DETAILS_ENDPOINT = + process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details'; +const SHOW_SETTINGS_MENU = process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU == 'true'; + +export function PageClientImpl(props: { + roomName: string; + region?: string; + hq: boolean; + codec: VideoCodec; +}) { + const [preJoinChoices, setPreJoinChoices] = React.useState( + undefined, + ); + const preJoinDefaults = React.useMemo(() => { + return { + username: '', + videoEnabled: true, + audioEnabled: true, + }; + }, []); + const [connectionDetails, setConnectionDetails] = React.useState( + undefined, + ); + + const handlePreJoinSubmit = React.useCallback(async (values: LocalUserChoices) => { + setPreJoinChoices(values); + const connectionDetailsResp = await fetch( + `${CONN_DETAILS_ENDPOINT}?roomName=${props.roomName}&participantName=${values.username}`, + ); + const connectionDetailsData = await connectionDetailsResp.json(); + setConnectionDetails(connectionDetailsData); + }, []); + const handlePreJoinError = React.useCallback((e: any) => console.error(e), []); + + return ( +
+ {connectionDetails === undefined || preJoinChoices === undefined ? ( +
+ +
+ ) : ( + + )} +
+ ); +} + +function VideoConferenceComponent(props: { + userChoices: LocalUserChoices; + connectionDetails: ConnectionDetails; + options: { + hq: boolean; + codec: VideoCodec; + }; +}) { + const e2eePassphrase = + typeof window !== 'undefined' && decodePassphrase(location.hash.substring(1)); + + const worker = + typeof window !== 'undefined' && + e2eePassphrase && + new Worker(new URL('livekit-client/e2ee-worker', import.meta.url)); + const e2eeEnabled = !!(e2eePassphrase && worker); + const keyProvider = new ExternalE2EEKeyProvider(); + + const roomOptions = React.useMemo((): RoomOptions => { + let videoCodec: VideoCodec | undefined = props.options.codec ? props.options.codec : 'vp9'; + if (e2eeEnabled && (videoCodec === 'av1' || videoCodec === 'vp9')) { + videoCodec = undefined; + } + return { + videoCaptureDefaults: { + deviceId: props.userChoices.videoDeviceId ?? undefined, + resolution: props.options.hq ? VideoPresets.h2160 : VideoPresets.h720, + }, + publishDefaults: { + dtx: false, + videoSimulcastLayers: props.options.hq + ? [VideoPresets.h1080, VideoPresets.h720] + : [VideoPresets.h540, VideoPresets.h216], + red: !e2eeEnabled, + videoCodec, + }, + audioCaptureDefaults: { + deviceId: props.userChoices.audioDeviceId ?? undefined, + }, + adaptiveStream: { pixelDensity: 'screen' }, + dynacast: true, + e2ee: e2eeEnabled + ? { + keyProvider, + worker, + } + : undefined, + }; + // @ts-ignore + setLogLevel('debug', 'lk-e2ee'); + }, [props.userChoices, props.options.hq, props.options.codec]); + + const room = React.useMemo(() => new Room(roomOptions), []); + + if (e2eeEnabled) { + keyProvider.setKey(decodePassphrase(e2eePassphrase)); + room.setE2EEEnabled(true).catch((e) => { + if (e instanceof DeviceUnsupportedError) { + alert( + `You're trying to join an encrypted meeting, but your browser does not support it. Please update it to the latest version and try again.`, + ); + console.error(e); + } + }); + } + const connectOptions = React.useMemo((): RoomConnectOptions => { + return { + autoSubscribe: true, + }; + }, []); + + const router = useRouter(); + const handleOnLeave = React.useCallback(() => router.push('/'), [router]); + + return ( + <> + + + + + + + ); +} diff --git a/app/rooms/[roomName]/page.tsx b/app/rooms/[roomName]/page.tsx index 70876d4..20ff39f 100644 --- a/app/rooms/[roomName]/page.tsx +++ b/app/rooms/[roomName]/page.tsx @@ -1,180 +1,26 @@ -'use client'; - -import { - LiveKitRoom, - VideoConference, - formatChatMessageLinks, - useToken, - LocalUserChoices, - PreJoin, -} from '@livekit/components-react'; -import { - DeviceUnsupportedError, - ExternalE2EEKeyProvider, - Room, - RoomConnectOptions, - RoomOptions, - VideoCodec, - VideoPresets, - setLogLevel, -} from 'livekit-client'; - -import { useRouter, useSearchParams } from 'next/navigation'; import * as React from 'react'; -import { DebugMode } from '@/lib/Debug'; -import { decodePassphrase, useServerUrl } from '@/lib/client-utils'; -import { SettingsMenu } from '@/lib/SettingsMenu'; -import { RecordingIndicator } from '@/lib/RecordingIndicator'; +import { PageClientImpl } from './PageClientImpl'; import { isVideoCodec } from '@/lib/types'; -export default function Page({ params }: { params: { roomName: string } }) { - const router = useRouter(); - const roomName = params.roomName; - const [preJoinChoices, setPreJoinChoices] = React.useState( - undefined, - ); - const preJoinDefaults = React.useMemo(() => { - return { - username: '', - videoEnabled: true, - audioEnabled: true, - }; - }, []); - const handlePreJoinSubmit = React.useCallback((values: LocalUserChoices) => { - setPreJoinChoices(values); - }, []); - const onPreJoinError = React.useCallback((e: any) => { - console.error(e); - }, []); - const onLeave = React.useCallback(() => router.push('/'), []); - - return ( -
- {roomName && !Array.isArray(roomName) && preJoinChoices ? ( - - ) : ( -
- -
- )} -
- ); -} - -function ActiveRoom(props: { - userChoices: LocalUserChoices; - roomName: string; - onLeave: () => void; +export default function Page({ + params, + searchParams, +}: { + params: { roomName: string }; + searchParams: { + // FIXME: We should not allow values for regions if in playground mode. + region?: string; + hq?: string; + codec?: string; + }; }) { - const searchParams = useSearchParams(); - const region = searchParams?.get('region'); - const hq = searchParams?.get('hq'); - const codec = searchParams?.get('codec'); - - const tokenOptions = React.useMemo(() => { - return { - userInfo: { - identity: props.userChoices.username, - name: props.userChoices.username, - }, - }; - }, [props.userChoices.username]); - const token = useToken(process.env.NEXT_PUBLIC_LK_TOKEN_ENDPOINT, props.roomName, tokenOptions); - - const e2eePassphrase = - typeof window !== 'undefined' && decodePassphrase(location.hash.substring(1)); - - const liveKitUrl = useServerUrl(typeof region === 'string' ? region : undefined); - - const worker = - typeof window !== 'undefined' && - e2eePassphrase && - new Worker(new URL('livekit-client/e2ee-worker', import.meta.url)); - const e2eeEnabled = !!(e2eePassphrase && worker); - const keyProvider = new ExternalE2EEKeyProvider(); - - const roomOptions = React.useMemo((): RoomOptions => { - let videoCodec: VideoCodec | undefined = codec && isVideoCodec(codec) ? codec : 'vp9'; - if (e2eeEnabled && (videoCodec === 'av1' || videoCodec === 'vp9')) { - videoCodec = undefined; - } - return { - videoCaptureDefaults: { - deviceId: props.userChoices.videoDeviceId ?? undefined, - resolution: hq === 'true' ? VideoPresets.h2160 : VideoPresets.h720, - }, - publishDefaults: { - dtx: false, - videoSimulcastLayers: - hq === 'true' - ? [VideoPresets.h1080, VideoPresets.h720] - : [VideoPresets.h540, VideoPresets.h216], - red: !e2eeEnabled, - videoCodec, - }, - audioCaptureDefaults: { - deviceId: props.userChoices.audioDeviceId ?? undefined, - }, - adaptiveStream: { pixelDensity: 'screen' }, - dynacast: true, - e2ee: e2eeEnabled - ? { - keyProvider, - worker, - } - : undefined, - }; - // @ts-ignore - setLogLevel('debug', 'lk-e2ee'); - }, [props.userChoices, hq, codec]); - - const room = React.useMemo(() => new Room(roomOptions), []); - - if (e2eeEnabled) { - keyProvider.setKey(decodePassphrase(e2eePassphrase)); - room.setE2EEEnabled(true).catch((e) => { - if (e instanceof DeviceUnsupportedError) { - alert( - `You're trying to join an encrypted meeting, but your browser does not support it. Please update it to the latest version and try again.`, - ); - console.error(e); - } - }); - } - const connectOptions = React.useMemo((): RoomConnectOptions => { - return { - autoSubscribe: true, - }; - }, []); - - if (!liveKitUrl) { - return null; - } + const codec = + typeof searchParams.codec === 'string' && isVideoCodec(searchParams.codec) + ? searchParams.codec + : 'vp9'; + const hq = searchParams.hq === 'true' ? true : false; return ( - <> - - - - - - + ); } diff --git a/lib/types.ts b/lib/types.ts index cb40839..896684a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -19,3 +19,10 @@ export interface TokenResult { export function isVideoCodec(codec: string): codec is VideoCodec { return videoCodecs.includes(codec as VideoCodec); } + +export type ConnectionDetails = { + serverUrl: string; + roomName: string; + participantName: string; + participantToken: string; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf7f1a6..7f79a44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2281,7 +2281,7 @@ snapshots: eslint: 9.8.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0) - eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.8.0) + eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@9.8.0) eslint-plugin-react: 7.33.2(eslint@9.8.0) eslint-plugin-react-hooks: 4.6.0(eslint@9.8.0) @@ -2305,7 +2305,7 @@ snapshots: enhanced-resolve: 5.17.1 eslint: 9.8.0 eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0) - eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.8.0) + eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0) fast-glob: 3.3.2 get-tsconfig: 4.7.2 is-core-module: 2.13.1 @@ -2327,7 +2327,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.8.0): + eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.10.0(eslint@9.8.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@9.8.0))(eslint@9.8.0))(eslint@9.8.0): dependencies: array-includes: 3.1.7 array.prototype.findlastindex: 1.2.3