Refactor app use connection details endpoint (#302)

* update to connection-details endpoint

* chore: Update .env.example

* chore: Update .env.example

* remove fallback logic

* chore: Update connection details property names
This commit is contained in:
Jonas Schell 2024-08-23 09:48:49 +02:00 committed by GitHub
parent 15e58cd797
commit f2f4ada03d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 287 additions and 272 deletions

View File

@ -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

View File

@ -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();
}

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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 });
}
}
}

View File

@ -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) {

View File

@ -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<LocalUserChoices | undefined>(
undefined,
);
const preJoinDefaults = React.useMemo(() => {
return {
username: '',
videoEnabled: true,
audioEnabled: true,
};
}, []);
const [connectionDetails, setConnectionDetails] = React.useState<ConnectionDetails | undefined>(
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 (
<main data-lk-theme="default">
{connectionDetails === undefined || preJoinChoices === undefined ? (
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
<PreJoin
defaults={preJoinDefaults}
onSubmit={handlePreJoinSubmit}
onError={handlePreJoinError}
/>
</div>
) : (
<VideoConferenceComponent
connectionDetails={connectionDetails}
userChoices={preJoinChoices}
options={{ codec: props.codec, hq: props.hq }}
/>
)}
</main>
);
}
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 (
<>
<LiveKitRoom
room={room}
token={props.connectionDetails.participantToken}
serverUrl={props.connectionDetails.serverUrl}
connectOptions={connectOptions}
video={props.userChoices.videoEnabled}
audio={props.userChoices.audioEnabled}
onDisconnected={handleOnLeave}
>
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
SettingsComponent={SHOW_SETTINGS_MENU ? SettingsMenu : undefined}
/>
<DebugMode />
<RecordingIndicator />
</LiveKitRoom>
</>
);
}

View File

@ -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<LocalUserChoices | undefined>(
undefined,
);
const preJoinDefaults = React.useMemo(() => {
return {
username: '',
videoEnabled: true,
audioEnabled: true,
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 handlePreJoinSubmit = React.useCallback((values: LocalUserChoices) => {
setPreJoinChoices(values);
}, []);
const onPreJoinError = React.useCallback((e: any) => {
console.error(e);
}, []);
const onLeave = React.useCallback(() => router.push('/'), []);
return (
<main data-lk-theme="default">
{roomName && !Array.isArray(roomName) && preJoinChoices ? (
<ActiveRoom roomName={roomName} userChoices={preJoinChoices} onLeave={onLeave}></ActiveRoom>
) : (
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
<PreJoin
onError={onPreJoinError}
defaults={preJoinDefaults}
onSubmit={handlePreJoinSubmit}
/>
</div>
)}
</main>
);
}
function ActiveRoom(props: {
userChoices: LocalUserChoices;
roomName: string;
onLeave: () => void;
}) {
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 (
<>
<LiveKitRoom
room={room}
token={token}
serverUrl={liveKitUrl}
connectOptions={connectOptions}
video={props.userChoices.videoEnabled}
audio={props.userChoices.audioEnabled}
onDisconnected={props.onLeave}
>
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
SettingsComponent={
process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU === 'true' ? SettingsMenu : undefined
}
/>
<DebugMode />
<RecordingIndicator />
</LiveKitRoom>
</>
<PageClientImpl roomName={params.roomName} region={searchParams.region} hq={hq} codec={codec} />
);
}

View File

@ -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;
};

6
pnpm-lock.yaml generated
View File

@ -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