From 15e58cd797bd4014f2e3fd8ebcef5dfaa7180c06 Mon Sep 17 00:00:00 2001 From: Jonas Schell Date: Wed, 21 Aug 2024 14:05:42 +0200 Subject: [PATCH] Migrate to Next app router (#297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Migrate Home Page to App Router * Update themeColor from layout.tsx * port room page to app router * small changes * port custom page to app router * port token and url api routes * port start stop routes * Refactor error handling in GET function * delete pages folder * remove unused function * remove deprecated field from docs: @deprecated — will be enabled by default and removed in Next.js 15 * wrap useSearchParams in Suspense * split up custom page into server and client component * update imports * simplify * Refactor error handling in GET function * refactor to use props for components * Refactor video codec validation and handling * Refactor LiveKitRoom component to handle null liveKitUrl * refactor: improve video codec validation and handling * add video codec typeguard * fix isVideoCodec --- .../start.ts => app/api/record/start/route.ts | 26 ++-- .../stop.ts => app/api/record/stop/route.ts | 27 ++-- pages/api/token.ts => app/api/token/route.ts | 42 ++--- app/api/url/route.ts | 20 +++ .../custom/VideoConferenceClientImpl.tsx | 63 ++++---- app/custom/page.tsx | 28 ++++ app/layout.tsx | 56 +++++++ pages/index.tsx => app/page.tsx | 70 ++++----- .../rooms/[roomName]/page.tsx | 146 ++++++++---------- lib/server-utils.ts | 20 +-- lib/types.ts | 7 +- next.config.js | 1 - pages/_app.tsx | 60 ------- pages/api/url.ts | 17 -- tsconfig.json | 12 +- 15 files changed, 288 insertions(+), 307 deletions(-) rename pages/api/record/start.ts => app/api/record/start/route.ts (72%) rename pages/api/record/stop.ts => app/api/record/stop/route.ts (61%) rename pages/api/token.ts => app/api/token/route.ts (54%) create mode 100644 app/api/url/route.ts rename pages/custom/index.tsx => app/custom/VideoConferenceClientImpl.tsx (54%) create mode 100644 app/custom/page.tsx create mode 100644 app/layout.tsx rename pages/index.tsx => app/page.tsx (80%) rename pages/rooms/[name].tsx => app/rooms/[roomName]/page.tsx (55%) delete mode 100644 pages/_app.tsx delete mode 100644 pages/api/url.ts diff --git a/pages/api/record/start.ts b/app/api/record/start/route.ts similarity index 72% rename from pages/api/record/start.ts rename to app/api/record/start/route.ts index 50aebdc..a07f381 100644 --- a/pages/api/record/start.ts +++ b/app/api/record/start/route.ts @@ -1,9 +1,11 @@ import { EgressClient, EncodedFileOutput, S3Upload } from 'livekit-server-sdk'; -import { NextApiRequest, NextApiResponse } from 'next'; +import { NextResponse } from 'next/server'; -export default async function startRecording(req: NextApiRequest, res: NextApiResponse) { +export async function GET(req: Request) { try { - const { roomName } = req.query; + const url = new URL(req.url); + const searchParams = url.searchParams; + const roomName = searchParams.get('roomName'); /** * CAUTION: @@ -13,9 +15,7 @@ export default async function startRecording(req: NextApiRequest, res: NextApiRe */ if (typeof roomName !== 'string') { - res.statusMessage = 'Missing roomName parameter'; - res.status(403).end(); - return; + return new NextResponse('Missing roomName parameter', { status: 403 }); } const { @@ -36,8 +36,7 @@ export default async function startRecording(req: NextApiRequest, res: NextApiRe const existingEgresses = await egressClient.listEgress({ roomName }); if (existingEgresses.length > 0 && existingEgresses.some((e) => e.status < 2)) { - (res.statusMessage = 'Meeting is already being recorded'), res.status(409).end(); - return; + return new NextResponse('Meeting is already being recorded', { status: 409 }); } const fileOutput = new EncodedFileOutput({ @@ -64,13 +63,10 @@ export default async function startRecording(req: NextApiRequest, res: NextApiRe }, ); - res.status(200).end(); - } catch (e) { - if (e instanceof Error) { - res.statusMessage = e.name; - console.error(e); - res.status(500).end(); - return; + return new NextResponse(null, { status: 200 }); + } catch (error) { + if (error instanceof Error) { + return new NextResponse(error.message, { status: 500 }); } } } diff --git a/pages/api/record/stop.ts b/app/api/record/stop/route.ts similarity index 61% rename from pages/api/record/stop.ts rename to app/api/record/stop/route.ts index cabe8a1..bfbfc49 100644 --- a/pages/api/record/stop.ts +++ b/app/api/record/stop/route.ts @@ -1,9 +1,11 @@ import { EgressClient } from 'livekit-server-sdk'; -import { NextApiRequest, NextApiResponse } from 'next'; +import { NextResponse } from 'next/server'; -export default async function stopRecording(req: NextApiRequest, res: NextApiResponse) { +export async function GET(req: Request) { try { - const { roomName } = req.query; + const url = new URL(req.url); + const searchParams = url.searchParams; + const roomName = searchParams.get('roomName'); /** * CAUTION: @@ -13,9 +15,7 @@ export default async function stopRecording(req: NextApiRequest, res: NextApiRes */ if (typeof roomName !== 'string') { - res.statusMessage = 'Missing roomName parameter'; - res.status(403).end(); - return; + return new NextResponse('Missing roomName parameter', { status: 403 }); } const { LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL } = process.env; @@ -28,19 +28,14 @@ export default async function stopRecording(req: NextApiRequest, res: NextApiRes (info) => info.status < 2, ); if (activeEgresses.length === 0) { - res.statusMessage = 'No active recording found'; - res.status(404).end(); - return; + return new NextResponse('No active recording found', { status: 404 }); } await Promise.all(activeEgresses.map((info) => egressClient.stopEgress(info.egressId))); - res.status(200).end(); - } catch (e) { - if (e instanceof Error) { - res.statusMessage = e.name; - console.error(e); - res.status(500).end(); - return; + return new NextResponse(null, { status: 200 }); + } catch (error) { + if (error instanceof Error) { + return new NextResponse(error.message, { status: 500 }); } } } diff --git a/pages/api/token.ts b/app/api/token/route.ts similarity index 54% rename from pages/api/token.ts rename to app/api/token/route.ts index 321c6b9..3503839 100644 --- a/pages/api/token.ts +++ b/app/api/token/route.ts @@ -1,8 +1,7 @@ -import { NextApiRequest, NextApiResponse } from 'next'; - import { AccessToken } from 'livekit-server-sdk'; import type { AccessTokenOptions, VideoGrant } from 'livekit-server-sdk'; -import { TokenResult } from '../../lib/types'; +import { TokenResult } from '@/lib/types'; +import { NextResponse } from 'next/server'; const apiKey = process.env.LIVEKIT_API_KEY; const apiSecret = process.env.LIVEKIT_API_SECRET; @@ -16,35 +15,35 @@ const createToken = (userInfo: AccessTokenOptions, grant: VideoGrant) => { const roomPattern = /\w{4}\-\w{4}/; -export default async function handleToken(req: NextApiRequest, res: NextApiResponse) { +export async function GET(req: Request) { try { - const { roomName, identity, name, metadata } = req.query; + 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') { - res.status(403).end(); - return; + return new NextResponse('Forbidden', { status: 401 }); + } + if (name === null) { + return new NextResponse('Provide a name.', { status: 400 }); } - if (Array.isArray(name)) { - throw Error('provide max one name'); + return new NextResponse('Provide only one room name.', { status: 400 }); } if (Array.isArray(metadata)) { - throw Error('provide max one metadata string'); + 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)) { - res.status(400).end(); - return; + return new NextResponse('Invalid room name format.', { status: 400 }); } - // if (!userSession.isAuthenticated) { - // res.status(403).end(); - // return; - // } - const grant: VideoGrant = { room: roomName, roomJoin: true, @@ -59,9 +58,10 @@ export default async function handleToken(req: NextApiRequest, res: NextApiRespo accessToken: token, }; - res.status(200).json(result); - } catch (e) { - res.statusMessage = (e as Error).message; - res.status(500).end(); + 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 new file mode 100644 index 0000000..c6c9426 --- /dev/null +++ b/app/api/url/route.ts @@ -0,0 +1,20 @@ +import { getLiveKitURL } from '../../../lib/server-utils'; +import { NextResponse } from 'next/server'; + +export async function GET(req: Request) { + 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 livekitUrl = getLiveKitURL(region); + return NextResponse.json({ url: livekitUrl }); + } catch (error) { + if (error instanceof Error) { + return new NextResponse(error.message, { status: 500 }); + } + } +} diff --git a/pages/custom/index.tsx b/app/custom/VideoConferenceClientImpl.tsx similarity index 54% rename from pages/custom/index.tsx rename to app/custom/VideoConferenceClientImpl.tsx index 5f1ee05..02b779c 100644 --- a/pages/custom/index.tsx +++ b/app/custom/VideoConferenceClientImpl.tsx @@ -1,4 +1,5 @@ 'use client'; + import { formatChatMessageLinks, LiveKitRoom, VideoConference } from '@livekit/components-react'; import { ExternalE2EEKeyProvider, @@ -6,33 +7,33 @@ import { Room, RoomConnectOptions, RoomOptions, - VideoCodec, VideoPresets, + type VideoCodec, } from 'livekit-client'; -import { useRouter } from 'next/router'; +import { DebugMode } from '@/lib/Debug'; import { useMemo } from 'react'; -import { decodePassphrase } from '../../lib/client-utils'; -import { DebugMode } from '../../lib/Debug'; +import { decodePassphrase } from '@/lib/client-utils'; +import { SettingsMenu } from '@/lib/SettingsMenu'; -export default function CustomRoomConnection() { - const router = useRouter(); - const { liveKitUrl, token, codec } = router.query; - - const e2eePassphrase = - typeof window !== 'undefined' && decodePassphrase(window.location.hash.substring(1)); +export function VideoConferenceClientImpl(props: { + liveKitUrl: string; + token: string; + codec: VideoCodec | undefined; +}) { const worker = typeof window !== 'undefined' && new Worker(new URL('livekit-client/e2ee-worker', import.meta.url)); const keyProvider = new ExternalE2EEKeyProvider(); + const e2eePassphrase = + typeof window !== 'undefined' ? decodePassphrase(window.location.hash.substring(1)) : undefined; const e2eeEnabled = !!(e2eePassphrase && worker); - const roomOptions = useMemo((): RoomOptions => { return { publishDefaults: { videoSimulcastLayers: [VideoPresets.h540, VideoPresets.h216], red: !e2eeEnabled, - videoCodec: codec as VideoCodec | undefined, + videoCodec: props.codec, }, adaptiveStream: { pixelDensity: 'screen' }, dynacast: true, @@ -56,28 +57,22 @@ export default function CustomRoomConnection() { }; }, []); - if (typeof liveKitUrl !== 'string') { - return

Missing LiveKit URL

; - } - if (typeof token !== 'string') { - return

Missing LiveKit token

; - } - return ( -
- {liveKitUrl && ( - - - - - )} -
+ + + + ); } diff --git a/app/custom/page.tsx b/app/custom/page.tsx new file mode 100644 index 0000000..c7d5a78 --- /dev/null +++ b/app/custom/page.tsx @@ -0,0 +1,28 @@ +import { videoCodecs } from 'livekit-client'; +import { VideoConferenceClientImpl } from './VideoConferenceClientImpl'; +import { isVideoCodec } from '@/lib/types'; + +export default function CustomRoomConnection(props: { + searchParams: { + liveKitUrl?: string; + token?: string; + codec?: string; + }; +}) { + const { liveKitUrl, token, codec } = props.searchParams; + if (typeof liveKitUrl !== 'string') { + return

Missing LiveKit URL

; + } + if (typeof token !== 'string') { + return

Missing LiveKit token

; + } + if (codec !== undefined && !isVideoCodec(codec)) { + return

Invalid codec, if defined it has to be [{videoCodecs.join(', ')}].

; + } + + return ( +
+ +
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..bf35374 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,56 @@ +import '../styles/globals.css'; +import '@livekit/components-styles'; +import '@livekit/components-styles/prefabs'; +import type { Metadata, Viewport } from 'next'; + +export const metadata: Metadata = { + title: { + default: 'LiveKit Meet | Conference app build with LiveKit open source', + template: '%s', + }, + description: + 'LiveKit is an open source WebRTC project that gives you everything needed to build scalable and real-time audio and/or video experiences in your applications.', + twitter: { + creator: '@livekitted', + site: '@livekitted', + card: 'summary_large_image', + }, + openGraph: { + url: 'https://meet.livekit.io', + images: [ + { + url: 'https://meet.livekit.io/images/livekit-meet-open-graph.png', + width: 2000, + height: 1000, + type: 'image/png', + }, + ], + siteName: 'LiveKit Meet', + }, + icons: { + icon: { + rel: 'icon', + url: '/favicon.ico', + }, + apple: [ + { + rel: 'apple-touch-icon', + url: '/images/livekit-apple-touch.png', + sizes: '180x180', + }, + { rel: 'mask-icon', url: '/images/livekit-safari-pinned-tab.svg', color: '#070707' }, + ], + }, +}; + +export const viewport: Viewport = { + themeColor: '#070707', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/pages/index.tsx b/app/page.tsx similarity index 80% rename from pages/index.tsx rename to app/page.tsx index 0f8ca17..d23d536 100644 --- a/pages/index.tsx +++ b/app/page.tsx @@ -1,19 +1,18 @@ -import type { GetServerSideProps, InferGetServerSidePropsType } from 'next'; -import { useRouter } from 'next/router'; -import React, { ReactElement, useState } from 'react'; -import { encodePassphrase, generateRoomId, randomString } from '../lib/client-utils'; +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import React, { Suspense, useState } from 'react'; +import { encodePassphrase, generateRoomId, randomString } from '@/lib/client-utils'; import styles from '../styles/Home.module.css'; -interface TabsProps { - children: ReactElement[]; - selectedIndex?: number; - onTabSelected?: (index: number) => void; -} +function Tabs(props: React.PropsWithChildren<{}>) { + const searchParams = useSearchParams(); + const tabIndex = searchParams?.get('tab') === 'custom' ? 1 : 0; -function Tabs(props: TabsProps) { - const activeIndex = props.selectedIndex ?? 0; - if (!props.children) { - return <>; + const router = useRouter(); + function onTabSelected(index: number) { + const tab = index === 1 ? 'custom' : 'demo'; + router.push(`/?tab=${tab}`); } let tabs = React.Children.map(props.children, (child, index) => { @@ -21,23 +20,28 @@ function Tabs(props: TabsProps) { ); }); + return (
{tabs}
- {props.children[activeIndex]} + {/* @ts-ignore */} + {props.children[tabIndex]}
); } -function DemoMeetingTab({ label }: { label: string }) { +function DemoMeetingTab(props: { label: string }) { const router = useRouter(); const [e2ee, setE2ee] = useState(false); const [sharedPassphrase, setSharedPassphrase] = useState(randomString(64)); @@ -80,7 +84,7 @@ function DemoMeetingTab({ label }: { label: string }) { ); } -function CustomConnectionTab({ label }: { label: string }) { +function CustomConnectionTab(props: { label: string }) { const router = useRouter(); const [e2ee, setE2ee] = useState(false); @@ -156,21 +160,7 @@ function CustomConnectionTab({ label }: { label: string }) { ); } -export const getServerSideProps: GetServerSideProps<{ tabIndex: number }> = async ({ - query, - res, -}) => { - res.setHeader('Cache-Control', 'public, max-age=7200'); - const tabIndex = query.tab === 'custom' ? 1 : 0; - return { props: { tabIndex } }; -}; - -const Home = ({ tabIndex }: InferGetServerSidePropsType) => { - const router = useRouter(); - function onTabSelected(index: number) { - const tab = index === 1 ? 'custom' : 'demo'; - router.push({ query: { tab } }); - } +export default function Page() { return ( <>
@@ -188,10 +178,12 @@ const Home = ({ tabIndex }: InferGetServerSidePropsType - - - - + + + + + +