chore: sentry and nextjs major bumps (#633)

* chore: remove nextjs-config

* build fix

* sentry update

* nextjs update

* feature flags doc

* update readme

* explicit nextjs env vars + remove feature-unrelated things and obsolete vars from config

* full config removal

* remove force-dynamic from pages

* compile fix

* restore claude-deleted tests

* no sentry backward compat

* better .env.example

* AUTHENTIK_REFRESH_TOKEN_URL not so required

* accommodate auth system to requiredLogin feature

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
This commit is contained in:
Igor Monadical
2025-09-12 12:41:44 -04:00
committed by GitHub
parent 43ea9349f5
commit 5cba5d310d
19 changed files with 2285 additions and 593 deletions

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { useState } from "react"; import { useState, use } from "react";
import TopicHeader from "./topicHeader"; import TopicHeader from "./topicHeader";
import TopicWords from "./topicWords"; import TopicWords from "./topicWords";
import TopicPlayer from "./topicPlayer"; import TopicPlayer from "./topicPlayer";
@@ -18,14 +18,16 @@ import { useRouter } from "next/navigation";
import { Box, Grid } from "@chakra-ui/react"; import { Box, Grid } from "@chakra-ui/react";
export type TranscriptCorrect = { export type TranscriptCorrect = {
params: { params: Promise<{
transcriptId: string; transcriptId: string;
}; }>;
}; };
export default function TranscriptCorrect({ export default function TranscriptCorrect(props: TranscriptCorrect) {
params: { transcriptId }, const params = use(props.params);
}: TranscriptCorrect) {
const { transcriptId } = params;
const updateTranscriptMutation = useTranscriptUpdate(); const updateTranscriptMutation = useTranscriptUpdate();
const transcript = useTranscriptGet(transcriptId); const transcript = useTranscriptGet(transcriptId);
const stateCurrentTopic = useState<GetTranscriptTopic>(); const stateCurrentTopic = useState<GetTranscriptTopic>();

View File

@@ -5,7 +5,7 @@ import useWaveform from "../useWaveform";
import useMp3 from "../useMp3"; import useMp3 from "../useMp3";
import { TopicList } from "./_components/TopicList"; import { TopicList } from "./_components/TopicList";
import { Topic } from "../webSocketTypes"; import { Topic } from "../webSocketTypes";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState, use } from "react";
import FinalSummary from "./finalSummary"; import FinalSummary from "./finalSummary";
import TranscriptTitle from "../transcriptTitle"; import TranscriptTitle from "../transcriptTitle";
import Player from "../player"; import Player from "../player";
@@ -15,13 +15,14 @@ import { useTranscriptGet } from "../../../lib/apiHooks";
import { TranscriptStatus } from "../../../lib/transcript"; import { TranscriptStatus } from "../../../lib/transcript";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: Promise<{
transcriptId: string; transcriptId: string;
}; }>;
}; };
export default function TranscriptDetails(details: TranscriptDetails) { export default function TranscriptDetails(details: TranscriptDetails) {
const transcriptId = details.params.transcriptId; const params = use(details.params);
const transcriptId = params.transcriptId;
const router = useRouter(); const router = useRouter();
const statusToRedirect = [ const statusToRedirect = [
"idle", "idle",
@@ -43,7 +44,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useEffect(() => { useEffect(() => {
if (waiting) { if (waiting) {
const newUrl = "/transcripts/" + details.params.transcriptId + "/record"; const newUrl = "/transcripts/" + params.transcriptId + "/record";
// Shallow redirection does not work on NextJS 13 // Shallow redirection does not work on NextJS 13
// https://github.com/vercel/next.js/discussions/48110 // https://github.com/vercel/next.js/discussions/48110
// https://github.com/vercel/next.js/discussions/49540 // https://github.com/vercel/next.js/discussions/49540

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, use } from "react";
import Recorder from "../../recorder"; import Recorder from "../../recorder";
import { TopicList } from "../_components/TopicList"; import { TopicList } from "../_components/TopicList";
import { useWebSockets } from "../../useWebSockets"; import { useWebSockets } from "../../useWebSockets";
@@ -14,19 +14,20 @@ import { useTranscriptGet } from "../../../../lib/apiHooks";
import { TranscriptStatus } from "../../../../lib/transcript"; import { TranscriptStatus } from "../../../../lib/transcript";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: Promise<{
transcriptId: string; transcriptId: string;
}; }>;
}; };
const TranscriptRecord = (details: TranscriptDetails) => { const TranscriptRecord = (details: TranscriptDetails) => {
const transcript = useTranscriptGet(details.params.transcriptId); const params = use(details.params);
const transcript = useTranscriptGet(params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false); const [transcriptStarted, setTranscriptStarted] = useState(false);
const useActiveTopic = useState<Topic | null>(null); const useActiveTopic = useState<Topic | null>(null);
const webSockets = useWebSockets(details.params.transcriptId); const webSockets = useWebSockets(params.transcriptId);
const mp3 = useMp3(details.params.transcriptId, true); const mp3 = useMp3(params.transcriptId, true);
const router = useRouter(); const router = useRouter();
@@ -47,7 +48,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
if (newStatus && (newStatus == "ended" || newStatus == "error")) { if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting"); console.log(newStatus, "redirecting");
const newUrl = "/transcripts/" + details.params.transcriptId; const newUrl = "/transcripts/" + params.transcriptId;
router.replace(newUrl); router.replace(newUrl);
} }
}, [webSockets.status?.value, transcript.data?.status]); }, [webSockets.status?.value, transcript.data?.status]);
@@ -75,7 +76,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
<WaveformLoading /> <WaveformLoading />
) : ( ) : (
// todo: only start recording animation when you get "recorded" status // todo: only start recording animation when you get "recorded" status
<Recorder transcriptId={details.params.transcriptId} status={status} /> <Recorder transcriptId={params.transcriptId} status={status} />
)} )}
<VStack <VStack
align={"left"} align={"left"}
@@ -98,7 +99,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
topics={webSockets.topics} topics={webSockets.topics}
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
autoscroll={true} autoscroll={true}
transcriptId={details.params.transcriptId} transcriptId={params.transcriptId}
status={status} status={status}
currentTranscriptText={webSockets.accumulatedText} currentTranscriptText={webSockets.accumulatedText}
/> />

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, use } from "react";
import { useWebSockets } from "../../useWebSockets"; import { useWebSockets } from "../../useWebSockets";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock"; import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -9,18 +9,19 @@ import FileUploadButton from "../../fileUploadButton";
import { useTranscriptGet } from "../../../../lib/apiHooks"; import { useTranscriptGet } from "../../../../lib/apiHooks";
type TranscriptUpload = { type TranscriptUpload = {
params: { params: Promise<{
transcriptId: string; transcriptId: string;
}; }>;
}; };
const TranscriptUpload = (details: TranscriptUpload) => { const TranscriptUpload = (details: TranscriptUpload) => {
const transcript = useTranscriptGet(details.params.transcriptId); const params = use(details.params);
const transcript = useTranscriptGet(params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false); const [transcriptStarted, setTranscriptStarted] = useState(false);
const webSockets = useWebSockets(details.params.transcriptId); const webSockets = useWebSockets(params.transcriptId);
const mp3 = useMp3(details.params.transcriptId, true); const mp3 = useMp3(params.transcriptId, true);
const router = useRouter(); const router = useRouter();
@@ -50,7 +51,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
if (newStatus && (newStatus == "ended" || newStatus == "error")) { if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting"); console.log(newStatus, "redirecting");
const newUrl = "/transcripts/" + details.params.transcriptId; const newUrl = "/transcripts/" + params.transcriptId;
router.replace(newUrl); router.replace(newUrl);
} }
}, [webSockets.status?.value, transcript.data?.status]); }, [webSockets.status?.value, transcript.data?.status]);
@@ -84,7 +85,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
Please select the file, supported formats: .mp3, m4a, .wav, Please select the file, supported formats: .mp3, m4a, .wav,
.mp4, .mov or .webm .mp4, .mov or .webm
</Text> </Text>
<FileUploadButton transcriptId={details.params.transcriptId} /> <FileUploadButton transcriptId={params.transcriptId} />
</> </>
)} )}
{status && status == "uploaded" && ( {status && status == "uploaded" && (

View File

@@ -7,6 +7,7 @@ import {
useState, useState,
useContext, useContext,
RefObject, RefObject,
use,
} from "react"; } from "react";
import { import {
Box, Box,
@@ -30,9 +31,9 @@ import { FaBars } from "react-icons/fa6";
import { useAuth } from "../lib/AuthProvider"; import { useAuth } from "../lib/AuthProvider";
export type RoomDetails = { export type RoomDetails = {
params: { params: Promise<{
roomName: string; roomName: string;
}; }>;
}; };
// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially // stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially
@@ -255,9 +256,10 @@ const useWhereby = () => {
}; };
export default function Room(details: RoomDetails) { export default function Room(details: RoomDetails) {
const params = use(details.params);
const wherebyLoaded = useWhereby(); const wherebyLoaded = useWhereby();
const wherebyRef = useRef<HTMLElement>(null); const wherebyRef = useRef<HTMLElement>(null);
const roomName = details.params.roomName; const roomName = params.roomName;
const meeting = useRoomMeeting(roomName); const meeting = useRoomMeeting(roomName);
const router = useRouter(); const router = useRouter();
const status = useAuth().status; const status = useAuth().status;

View File

@@ -1,6 +1,6 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import { authOptions } from "../../../lib/authBackend"; import { authOptions } from "../../../lib/authBackend";
const handler = NextAuth(authOptions); const handler = NextAuth(authOptions());
export { handler as GET, handler as POST }; export { handler as GET, handler as POST };

View File

@@ -6,6 +6,7 @@ import ErrorMessage from "./(errors)/errorMessage";
import { RecordingConsentProvider } from "./recordingConsentContext"; import { RecordingConsentProvider } from "./recordingConsentContext";
import { ErrorBoundary } from "@sentry/nextjs"; import { ErrorBoundary } from "@sentry/nextjs";
import { Providers } from "./providers"; import { Providers } from "./providers";
import { assertExistsAndNonEmptyString } from "./lib/utils";
const poppins = Poppins({ const poppins = Poppins({
subsets: ["latin"], subsets: ["latin"],
@@ -20,8 +21,13 @@ export const viewport: Viewport = {
maximumScale: 1, maximumScale: 1,
}; };
const NEXT_PUBLIC_SITE_URL = assertExistsAndNonEmptyString(
process.env.NEXT_PUBLIC_SITE_URL,
"NEXT_PUBLIC_SITE_URL required",
);
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!), metadataBase: new URL(NEXT_PUBLIC_SITE_URL),
title: { title: {
template: "%s Reflector", template: "%s Reflector",
default: "Reflector - AI-Powered Meeting Transcriptions by Monadical", default: "Reflector - AI-Powered Meeting Transcriptions by Monadical",

View File

@@ -9,6 +9,7 @@ import { Session } from "next-auth";
import { SessionAutoRefresh } from "./SessionAutoRefresh"; import { SessionAutoRefresh } from "./SessionAutoRefresh";
import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth"; import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth";
import { assertExists } from "./utils"; import { assertExists } from "./utils";
import { featureEnabled } from "./features";
type AuthContextType = ( type AuthContextType = (
| { status: "loading" } | { status: "loading" }
@@ -27,33 +28,50 @@ type AuthContextType = (
}; };
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
const isAuthEnabled = featureEnabled("requireLogin");
const noopAuthContext: AuthContextType = {
status: "unauthenticated",
update: async () => {
return null;
},
signIn: async () => {
throw new Error("signIn not supposed to be called");
},
signOut: async () => {
throw new Error("signOut not supposed to be called");
},
};
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: session, status, update } = useNextAuthSession(); const { data: session, status, update } = useNextAuthSession();
const customSession = session ? assertCustomSession(session) : null;
const contextValue: AuthContextType = { const contextValue: AuthContextType = isAuthEnabled
? {
...(() => { ...(() => {
switch (status) { switch (status) {
case "loading": { case "loading": {
const sessionIsHere = !!customSession; const sessionIsHere = !!session;
switch (sessionIsHere) { // actually exists sometimes; nextAuth types are something else
switch (sessionIsHere as boolean) {
case false: { case false: {
return { status }; return { status };
} }
case true: { case true: {
return { return {
status: "refreshing" as const, status: "refreshing" as const,
user: assertExists(customSession).user, user: assertCustomSession(
assertExists(session as unknown as Session),
).user,
}; };
} }
default: { default: {
const _: never = sessionIsHere;
throw new Error("unreachable"); throw new Error("unreachable");
} }
} }
} }
case "authenticated": { case "authenticated": {
const customSession = assertCustomSession(session);
if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) { if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) {
// token had expired but next auth still returns "authenticated" so show user unauthenticated state // token had expired but next auth still returns "authenticated" so show user unauthenticated state
return { return {
@@ -85,7 +103,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
update, update,
signIn, signIn,
signOut, signOut,
}; }
: noopAuthContext;
// not useEffect, we need it ASAP // not useEffect, we need it ASAP
// apparently, still no guarantee this code runs before mutations are fired // apparently, still no guarantee this code runs before mutations are fired

View File

@@ -7,7 +7,10 @@ import { assertExistsAndNonEmptyString } from "./utils";
import { isBuildPhase } from "./next"; import { isBuildPhase } from "./next";
export const API_URL = !isBuildPhase export const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL) ? assertExistsAndNonEmptyString(
process.env.NEXT_PUBLIC_API_URL,
"NEXT_PUBLIC_API_URL required",
)
: "http://localhost"; : "http://localhost";
// TODO decide strict validation or not // TODO decide strict validation or not

12
www/app/lib/array.ts Normal file
View File

@@ -0,0 +1,12 @@
export type NonEmptyArray<T> = [T, ...T[]];
export const isNonEmptyArray = <T>(arr: T[]): arr is NonEmptyArray<T> =>
arr.length > 0;
export const assertNonEmptyArray = <T>(
arr: T[],
err?: string,
): NonEmptyArray<T> => {
if (isNonEmptyArray(arr)) {
return arr;
}
throw new Error(err ?? "Expected non-empty array");
};

View File

@@ -19,20 +19,41 @@ import {
} from "./redisTokenCache"; } from "./redisTokenCache";
import { tokenCacheRedis, redlock } from "./redisClient"; import { tokenCacheRedis, redlock } from "./redisClient";
import { isBuildPhase } from "./next"; import { isBuildPhase } from "./next";
import { sequenceThrows } from "./errorUtils";
import { featureEnabled } from "./features";
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE; const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
const CLIENT_ID = !isBuildPhase const getAuthentikClientId = () =>
? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_ID) assertExistsAndNonEmptyString(
: "noop"; process.env.AUTHENTIK_CLIENT_ID,
const CLIENT_SECRET = !isBuildPhase "AUTHENTIK_CLIENT_ID required",
? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_SECRET) );
: "noop"; const getAuthentikClientSecret = () =>
assertExistsAndNonEmptyString(
process.env.AUTHENTIK_CLIENT_SECRET,
"AUTHENTIK_CLIENT_SECRET required",
);
const getAuthentikRefreshTokenUrl = () =>
assertExistsAndNonEmptyString(
process.env.AUTHENTIK_REFRESH_TOKEN_URL,
"AUTHENTIK_REFRESH_TOKEN_URL required",
);
export const authOptions: AuthOptions = { export const authOptions = (): AuthOptions =>
featureEnabled("requireLogin")
? {
providers: [ providers: [
AuthentikProvider({ AuthentikProvider({
clientId: CLIENT_ID, ...(() => {
clientSecret: CLIENT_SECRET, const [clientId, clientSecret] = sequenceThrows(
getAuthentikClientId,
getAuthentikClientSecret,
);
return {
clientId,
clientSecret,
};
})(),
issuer: process.env.AUTHENTIK_ISSUER, issuer: process.env.AUTHENTIK_ISSUER,
authorization: { authorization: {
params: { params: {
@@ -114,6 +135,9 @@ export const authOptions: AuthOptions = {
} satisfies CustomSession; } satisfies CustomSession;
}, },
}, },
}
: {
providers: [],
}; };
async function lockedRefreshAccessToken( async function lockedRefreshAccessToken(
@@ -174,16 +198,19 @@ async function lockedRefreshAccessToken(
} }
async function refreshAccessToken(token: JWT): Promise<JWTWithAccessToken> { async function refreshAccessToken(token: JWT): Promise<JWTWithAccessToken> {
const [url, clientId, clientSecret] = sequenceThrows(
getAuthentikRefreshTokenUrl,
getAuthentikClientId,
getAuthentikClientSecret,
);
try { try {
const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`;
const options = { const options = {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
body: new URLSearchParams({ body: new URLSearchParams({
client_id: process.env.AUTHENTIK_CLIENT_ID as string, client_id: clientId,
client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string, client_secret: clientSecret,
grant_type: "refresh_token", grant_type: "refresh_token",
refresh_token: token.refreshToken as string, refresh_token: token.refreshToken as string,
}).toString(), }).toString(),

View File

@@ -1,4 +1,6 @@
function shouldShowError(error: Error | null | undefined) { import { isNonEmptyArray, NonEmptyArray } from "./array";
export function shouldShowError(error: Error | null | undefined) {
if ( if (
error?.name == "ResponseError" && error?.name == "ResponseError" &&
(error["response"].status == 404 || error["response"].status == 403) (error["response"].status == 404 || error["response"].status == 403)
@@ -8,4 +10,40 @@ function shouldShowError(error: Error | null | undefined) {
return true; return true;
} }
export { shouldShowError }; const defaultMergeErrors = (ex: NonEmptyArray<unknown>): unknown => {
try {
return new Error(
ex
.map((e) =>
e ? (e.toString ? e.toString() : JSON.stringify(e)) : `${e}`,
)
.join("\n"),
);
} catch (e) {
console.error("Error merging errors:", e);
return ex[0];
}
};
type ReturnTypes<T extends readonly (() => any)[]> = {
[K in keyof T]: T[K] extends () => infer R ? R : never;
};
// sequence semantic for "throws"
// calls functions passed and collects its thrown values
export function sequenceThrows<Fns extends readonly (() => any)[]>(
...fs: Fns
): ReturnTypes<Fns> {
const results: unknown[] = [];
const errors: unknown[] = [];
for (const f of fs) {
try {
results.push(f());
} catch (e) {
errors.push(e);
}
}
if (errors.length) throw defaultMergeErrors(errors as NonEmptyArray<unknown>);
return results as ReturnTypes<Fns>;
}

View File

@@ -11,11 +11,11 @@ export type FeatureName = (typeof FEATURES)[number];
export type Features = Readonly<Record<FeatureName, boolean>>; export type Features = Readonly<Record<FeatureName, boolean>>;
export const DEFAULT_FEATURES: Features = { export const DEFAULT_FEATURES: Features = {
requireLogin: false, requireLogin: true,
privacy: true, privacy: true,
browse: false, browse: true,
sendToZulip: false, sendToZulip: true,
rooms: false, rooms: true,
} as const; } as const;
function parseBooleanEnv( function parseBooleanEnv(

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, use } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
@@ -30,9 +30,9 @@ const FORM_FIELDS = {
}; };
export type WebinarDetails = { export type WebinarDetails = {
params: { params: Promise<{
title: string; title: string;
}; }>;
}; };
export type Webinar = { export type Webinar = {
@@ -63,7 +63,8 @@ const WEBINARS: Webinar[] = [
]; ];
export default function WebinarPage(details: WebinarDetails) { export default function WebinarPage(details: WebinarDetails) {
const title = details.params.title; const params = use(details.params);
const title = params.title;
const webinar = WEBINARS.find((webinar) => webinar.title === title); const webinar = WEBINARS.find((webinar) => webinar.title === title);
if (!webinar) { if (!webinar) {
return notFound(); return notFound();

View File

@@ -23,3 +23,5 @@ if (SENTRY_DSN) {
replaysSessionSampleRate: 0.0, replaysSessionSampleRate: 0.0,
}); });
} }
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

9
www/instrumentation.ts Normal file
View File

@@ -0,0 +1,9 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
}
}

View File

@@ -1,7 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone", output: "standalone",
experimental: { esmExternals: "loose" },
env: { env: {
IS_CI: process.env.IS_CI, IS_CI: process.env.IS_CI,
}, },

View File

@@ -17,19 +17,19 @@
"@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@sentry/nextjs": "^7.77.0", "@sentry/nextjs": "^10.11.0",
"@tanstack/react-query": "^5.85.9", "@tanstack/react-query": "^5.85.9",
"@types/ioredis": "^5.0.0", "@types/ioredis": "^5.0.0",
"@whereby.com/browser-sdk": "^3.3.4", "@whereby.com/browser-sdk": "^3.3.4",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"axios": "^1.8.2", "axios": "^1.8.2",
"eslint": "^9.33.0", "eslint": "^9.33.0",
"eslint-config-next": "^14.2.31", "eslint-config-next": "^15.5.3",
"fontawesome": "^5.6.3", "fontawesome": "^5.6.3",
"ioredis": "^5.7.0", "ioredis": "^5.7.0",
"jest-worker": "^29.6.2", "jest-worker": "^29.6.2",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^14.2.30", "next": "^15.5.3",
"next-auth": "^4.24.7", "next-auth": "^4.24.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nuqs": "^2.4.3", "nuqs": "^2.4.3",

2379
www/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff