From f05c2f26fff3cbd5432232412d1ac71e499d429e Mon Sep 17 00:00:00 2001 From: mdadeel Date: Sun, 24 May 2026 19:31:08 +0600 Subject: [PATCH 1/2] refactor: update home page UI and skeleton animations --- .../plans/2026-05-24-websocket-refactor.md | 321 ++++++++++++++++++ .../sessions/[id]/rehydrate/route.js | 2 +- src/app/api/interview/sessions/route.js | 2 +- src/app/contests/[id]/result/page.jsx | 37 +- src/components/ui/skeleton.jsx | 6 +- src/context/ProblemSolveContext.jsx | 10 +- .../contests/components/ContestDetailPage.jsx | 36 +- src/features/interview/InterviewShell.jsx | 204 ++++------- src/features/interview/ScorecardView.jsx | 87 ++--- .../components/SubmissionResultTab.jsx | 7 +- src/lib/auth/wsToken.js | 29 -- src/lib/socket-server.js | 38 ++- src/lib/ws-token.js | 13 + src/socket/namespaces/interview.js | 2 +- src/socket/namespaces/voice.js | 2 +- 15 files changed, 510 insertions(+), 286 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-24-websocket-refactor.md delete mode 100644 src/lib/auth/wsToken.js diff --git a/docs/superpowers/plans/2026-05-24-websocket-refactor.md b/docs/superpowers/plans/2026-05-24-websocket-refactor.md new file mode 100644 index 00000000..81b1e3f1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-websocket-refactor.md @@ -0,0 +1,321 @@ +# WebSocket Integration Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. + +**Goal:** Single, correct WebSocket integration — one token utility, one client hook, all consumers using it. + +**Architecture:** Consolidate `src/lib/auth/wsToken.js` into `src/lib/ws-token.js`, make `useSecureSocket` the sole client-side entry point (with serverless bypass, SSL detection, token refresh), migrate all 4 direct `io()` callers, and fix the Redis crash bug in `socket-server.js`. + +**Tech Stack:** socket.io 4.x, socket.io-client 4.x, Next.js App Router, JWT + +--- + +### Task 1: Consolidate token utilities into `src/lib/ws-token.js` + +**Files:** +- Delete: `src/lib/auth/wsToken.js` +- Modify: `src/lib/ws-token.js` — add `signWsToken` convenience (with `type: 'websocket'` baked in) to replace the deleted file +- Modify: `src/socket/namespaces/voice.js:2` — switch import from `@/lib/auth/wsToken` to `@/lib/ws-token` +- Modify: `src/app/api/interview/sessions/route.js:7` — switch import from `@/lib/auth/wsToken` to `@/lib/ws-token` +- Modify: `src/app/api/interview/sessions/[id]/rehydrate/route.js:15` — switch import from `@/lib/auth/wsToken` to `@/lib/ws-token` + +**Step 1: Add `signWsToken` to `src/lib/ws-token.js`** + +Add after the existing `generateWsToken` function: + +```js +/** + * Generates a short-lived token for WebSocket authentication. + * @param {Object} payload — e.g. { userId, sessionId } + * @returns {string} Signed JWT valid for 15 minutes with type: 'websocket' + */ +export function signWsToken(payload) { + if (!JWT_SECRET) { + throw new Error('JWT_SECRET environment variable is required') + } + return jwt.sign( + { ...payload, type: 'websocket' }, + JWT_SECRET, + { expiresIn: '15m' } + ) +} +``` + +This ensures all tokens carry `type: 'websocket'` so the main auth middleware's type check passes. + +**Step 2: Update voice.js import** + +```js +// src/socket/namespaces/voice.js line 2 +// Before: +import { verifyWsToken } from '@/lib/auth/wsToken' +// After: +import { verifyWsToken } from '@/lib/ws-token' +``` + +**Step 3: Update interview session route** + +```js +// src/app/api/interview/sessions/route.js line 7 +// Before: +import { signWsToken } from '@/lib/auth/wsToken' +// After: +import { signWsToken } from '@/lib/ws-token' +``` + +**Step 4: Update interview rehydrate route** + +```js +// src/app/api/interview/sessions/[id]/rehydrate/route.js +// Before: +import { signWsToken } from '@/lib/auth/wsToken' +// After: +import { signWsToken } from '@/lib/ws-token' +``` + +**Step 5: Delete `src/lib/auth/wsToken.js`** + +```bash +rm src/lib/auth/wsToken.js +``` + +**Step 6: Run tests/lint** + +```bash +npx prettier --write src/lib/ws-token.js src/socket/namespaces/voice.js src/app/api/interview/sessions/route.js src/app/api/interview/sessions/\[id\]/rehydrate/route.js +npx eslint --fix src/lib/ws-token.js src/socket/namespaces/voice.js src/app/api/interview/sessions/route.js src/app/api/interview/sessions/\[id\]/rehydrate/route.js +``` + +--- + +### Task 2: Fix Redis crash in socket-server.js + +**Files:** +- Modify: `src/lib/socket-server.js:111-113` + +**Problem:** `redisClient.duplicate()` is called before the try/catch block. If `redisClient` was never connected or initialized, this throws and kills the entire Socket.IO server. + +**Step 1: Move `duplicate()` calls inside try/catch with null checks** + +```js +// Replace lines 111-135: +let adapterEnabled = false +let pubClient = null +let subClient = null +try { + pubClient = redisClient?.duplicate() + subClient = redisClient?.duplicate() + if (!pubClient || !subClient) { + console.warn('[Socket.IO] Redis client unavailable, running without adapter') + } else { + const [pubReady, subReady] = await Promise.all([ + safeConnectRedisClient(pubClient, 'Redis adapter pubClient'), + safeConnectRedisClient(subClient, 'Redis adapter subClient'), + ]) + if (pubReady && subReady) { + serverIo.adapter(createAdapter(pubClient, subClient)) + adapterEnabled = true + } else { + await Promise.all([ + safeQuitRedisClient(pubClient), + safeQuitRedisClient(subClient), + ]) + console.warn('[Socket.IO] Redis adapter disabled, running in single-node mode') + } + } +} catch (adapterError) { + if (pubClient) safeQuitRedisClient(pubClient) + if (subClient) safeQuitRedisClient(subClient) + console.warn( + `[Socket.IO] Redis adapter setup failed, continuing without adapter: ${adapterError.message}` + ) +} +``` + +--- + +### Task 3: Fix `useSecureSocket` token refresh race + +**Files:** +- Modify: `src/hooks/useSecureSocket.js:225` + +**Problem:** When token refresh fires but the socket is still connected, the new token is stored but never applied until disconnect. Should force a reconnect with the fresh token. + +**Step 1: Replace the reconnect-if-disconnected logic with always-reconnect** + +Replace lines 218-232: + +```js +refreshTimerRef.current = setTimeout(async () => { + try { + console.log('[useSecureSocket] Refreshing authentication token') + const { wsToken } = await fetchWsToken() + tokenRef.current = wsToken + + // Always reconnect to apply the new token + if (socketRef.current) { + socketRef.current.disconnect() + } + await connect() + } catch (err) { + console.error('[useSecureSocket] Token refresh failed:', err.message) + } +}, delay) +``` + +--- + +### Task 4: Migrate `ScorecardView.jsx` to `useSecureSocket` + +**Files:** +- Modify: `src/features/interview/ScorecardView.jsx` + +**Step 1: Replace direct `io()` with `useSecureSocket`** + +Remove the socket setup code in the useEffect (lines 70-108). Add the hook: + +```js +import { useSecureSocket } from '@/hooks/useSecureSocket' + +// Inside component: +const { socket, isConnected } = useSecureSocket('/interview', { + sessionId, + scope: 'interview', + onConnect: useCallback(async () => { + setIsSocketConnected(true) + socketRef.current = socket + socket?.emit('interview:join') + if (!resultFoundRef.current) { + await fetchResult() + } + }, [sessionId]), + onDisconnect: useCallback(() => setIsSocketConnected(false), []), +}) +``` + +Replace `socketRef.current` usage with the `socket` from the hook. + +Remove the `/rehydrate` fetch call since `useSecureSocket` already handles token fetching via `/api/auth/ws-token`. + +--- + +### Task 5: Migrate `ContestDetailPage.jsx` to `useSecureSocket` + +**Files:** +- Modify: `src/features/contests/components/ContestDetailPage.jsx` + +**Step 1: Remove direct `io` import, add `useSecureSocket`** + +```js +// Remove: +import { io } from 'socket.io-client' +// Add: +import { useSecureSocket } from '@/hooks/useSecureSocket' +``` + +**Step 2: Replace direct socket with hook** + +```js +const { socket, isConnected } = useSecureSocket('', { + scope: 'general', +}) +``` + +Remove the raw `io()` call. Move socket event listeners into a `useEffect` that watches `socket`. + +--- + +### Task 6: Migrate contest `result/page.jsx` to `useSecureSocket` + +**Files:** +- Modify: `src/app/contests/[id]/result/page.jsx` + +**Step 1: Replace `require('socket.io-client')` with `useSecureSocket`** + +This component is likely a client component already. Add: + +```js +import { useSecureSocket } from '@/hooks/useSecureSocket' +``` + +**Step 2: Use the hook** + +```js +const { socket } = useSecureSocket('', { + scope: 'general', + onConnect: useCallback(() => { + console.log('[ResultPage] Socket connected') + }, []), +}) +``` + +Add a `useEffect` that watches `socket` and attaches the `contest:result_finalized` listener, cleaning up on unmount. + +--- + +### Task 7: Migrate `InterviewShell.jsx` to `useSecureSocket` + +**Files:** +- Modify: `src/features/interview/InterviewShell.jsx` + +**Step 1: Replace direct `io` import** + +```js +// Remove: +import { io } from 'socket.io-client' +// Add: +import { useSecureSocket } from '@/hooks/useSecureSocket' +``` + +**Step 2: Replace socket creation with hook** + +The InterviewShell likely connects to `/interview` namespace. The interview flow gets its sessionId and token from the session creation response, so use: + +```js +const { socket, isConnected } = useSecureSocket('/interview', { + sessionId: sessionIdFromProps, + scope: 'interview', +}) +``` + +Remove `useRef` and `useEffect` that create the raw socket. Clean up any manual token handling. + +--- + +### Task 8: Remove hardcoded localhost URLs + +**Files:** +- Remove from: `ScorecardView.jsx:76`, `result/page.jsx:66` + +All hardcoded `http://localhost:...` fallbacks become dead code after migration since `useSecureSocket` derives the URL dynamically from `/api/auth/ws-token`. + +After Tasks 4-7 are complete, verify no `localhost` socket URLs remain: + +```bash +rg 'localhost.*socket' src/ --include='*.{jsx,js}' +# Expected: 0 matches (false positives possible, inspect each) +``` + +--- + +### Verification + +**Step 1: Check no remaining references to `@/lib/auth/wsToken`** + +```bash +rg 'from.*auth/wsToken' src/ +# Expected: 0 matches +``` + +**Step 2: Check no direct `io()` calls remain** + +```bash +rg "from 'socket.io-client'" src/ --include='*.{jsx,js}' +# Expected: 0 matches (useSecureSocket replaces all) +``` + +**Step 3: Build check** + +```bash +npx next build --no-lint 2>&1 | head -30 +``` diff --git a/src/app/api/interview/sessions/[id]/rehydrate/route.js b/src/app/api/interview/sessions/[id]/rehydrate/route.js index 590d46cb..5d80ab98 100644 --- a/src/app/api/interview/sessions/[id]/rehydrate/route.js +++ b/src/app/api/interview/sessions/[id]/rehydrate/route.js @@ -5,7 +5,7 @@ import { InterviewSnapshot } from '@/models/InterviewSnapshot.model' import { InterviewSession } from '@/models/InterviewSession.model' import { Problem } from '@/models/Problem.models' import { protect } from '@/middlewares/auth.middleware' -import { signWsToken } from '@/lib/auth/wsToken' +import { signWsToken } from '@/lib/ws-token' import { asyncHandler } from '@/lib/asyncHandler' import { redisClient } from '@/lib/redis' import { aiEnginePort } from '@/lib/ai-engine' diff --git a/src/app/api/interview/sessions/route.js b/src/app/api/interview/sessions/route.js index 1f7c88ed..2976fea2 100644 --- a/src/app/api/interview/sessions/route.js +++ b/src/app/api/interview/sessions/route.js @@ -4,7 +4,7 @@ import { InterviewSession } from '@/models/InterviewSession.model' import { createSession } from '@/services/interviewSession.service' import dbConnect from '@/lib/mongodb' import { asyncHandler } from '@/lib/asyncHandler' -import { signWsToken } from '@/lib/auth/wsToken' +import { signWsToken } from '@/lib/ws-token' import { Problem } from '@/models/Problem.models' export const POST = asyncHandler(async (req) => { diff --git a/src/app/contests/[id]/result/page.jsx b/src/app/contests/[id]/result/page.jsx index 169c4b56..09b300f5 100644 --- a/src/app/contests/[id]/result/page.jsx +++ b/src/app/contests/[id]/result/page.jsx @@ -23,6 +23,7 @@ import Confetti from 'react-confetti' import { useWindowSize } from 'react-use' import { toPng } from 'html-to-image' import { toast } from 'sonner' +import { useSecureSocket } from '@/hooks/useSecureSocket' const fetcher = (url) => fetch(url).then((res) => res.json()) @@ -34,6 +35,9 @@ export default function PersonalResultPage() { const resultRef = useRef(null) const [isSharing, setIsSharing] = useState(false) const [isDownloading, setIsDownloading] = useState(false) + const { socket } = useSecureSocket('', { + scope: 'general', + }) const { data, error, isLoading, mutate } = useSWR( contestId ? `/api/contests/${contestId}/my-result` : null, @@ -57,34 +61,21 @@ export default function PersonalResultPage() { // Listen for the worker's 'contest:result_finalized' signal via Socket.IO // This instantly kills polling and triggers a final SWR revalidation useEffect(() => { - if (!contestId || isResultConsistent) return + if (!socket || !contestId || isResultConsistent) return - let socket - try { - const { io } = require('socket.io-client') - const socketPort = process.env.NEXT_PUBLIC_SOCKET_PORT || '3002' - const fallbackSocketUrl = `${window.location.protocol}//${window.location.hostname}:${socketPort}` - socket = io(process.env.NEXT_PUBLIC_SOCKET_URL || fallbackSocketUrl) - - socket.on('connect', () => { - console.log('[ResultPage] Socket connected, listening for result_finalized') - }) - - socket.on('contest:result_finalized', (payload) => { - console.log('[ResultPage] Received contest:result_finalized:', payload) - if (payload.contestId === contestId) { - // Immediately revalidate SWR to get the final, consistent data - mutate() - } - }) - } catch (err) { - console.warn('[ResultPage] Socket connection failed, relying on polling:', err) + const handleResultFinalized = (payload) => { + console.log('[ResultPage] Received contest:result_finalized:', payload) + if (payload.contestId === contestId) { + mutate() + } } + socket.on('contest:result_finalized', handleResultFinalized) + return () => { - if (socket) socket.disconnect() + socket.off('contest:result_finalized', handleResultFinalized) } - }, [contestId, isResultConsistent, mutate]) + }, [socket, contestId, isResultConsistent, mutate]) // Format penalty (seconds to HH:MM:SS) const formatPenalty = (seconds) => { diff --git a/src/components/ui/skeleton.jsx b/src/components/ui/skeleton.jsx index dc802127..a2d3160d 100644 --- a/src/components/ui/skeleton.jsx +++ b/src/components/ui/skeleton.jsx @@ -7,11 +7,7 @@ import { cn } from '@/lib/utils' function Skeleton({ className, ...props }) { return (
) diff --git a/src/context/ProblemSolveContext.jsx b/src/context/ProblemSolveContext.jsx index 0f3c0a60..260b176c 100644 --- a/src/context/ProblemSolveContext.jsx +++ b/src/context/ProblemSolveContext.jsx @@ -73,7 +73,15 @@ function ProblemSolveProviderInner({ // Local UI state const [isConsoleOpen, setIsConsoleOpen] = useState(true) - const [leftTab, setLeftTabState] = useState(() => readSavedLeftTab(problemId)) + const [leftTab, setLeftTabState] = useState('description') + + // Restore persisted tab after hydration to avoid SSR/client mismatch + useEffect(() => { + const savedTab = readSavedLeftTab(problemId) + if (savedTab !== 'description') { + setLeftTabState(savedTab) + } + }, [problemId]) const setLeftTab = useCallback((nextTab) => { setLeftTabState((prevTab) => { diff --git a/src/features/contests/components/ContestDetailPage.jsx b/src/features/contests/components/ContestDetailPage.jsx index 2c33988e..e6a281ad 100644 --- a/src/features/contests/components/ContestDetailPage.jsx +++ b/src/features/contests/components/ContestDetailPage.jsx @@ -2,7 +2,7 @@ import React, { useMemo, useEffect } from 'react' import useSWR from 'swr' -import { io } from 'socket.io-client' +import { useSecureSocket } from '@/hooks/useSecureSocket' import { useContest } from '@/hooks/useContest' import { useContestTimer } from '@/hooks/useContestTimer' import ContestDetailHero from './ContestDetailHero' @@ -43,35 +43,39 @@ export default function ContestDetailPage({ contestId }) { const isRegistered = regData?.isRegistered || false const totalParticipants = participantsData?.pagination?.total || participants.length + const { socket } = useSecureSocket('', { + scope: 'general', + }) + // Real-time synchronization useEffect(() => { - if (!contestId) return - - const fallbackSocketBaseUrl = `http://localhost:${process.env.NEXT_PUBLIC_SOCKET_PORT || '3002'}` + if (!socket || !contestId) return - const socket = io(process.env.NEXT_PUBLIC_SOCKET_URL || fallbackSocketBaseUrl, { - reconnection: true, - }) - - socket.on('connect', () => { + const handleConnect = () => { console.log(`[ContestDetail] Joined room contest_${contestId}`) socket.emit('join_room', `contest_${contestId}`) - }) + } - socket.on('contest:updated', (data) => { + const handleUpdated = (data) => { console.log('[ContestDetail] Contest updated, refreshing state...', data) mutate() - }) + } - socket.on('contest:deleted', () => { + const handleDeleted = () => { console.log('[ContestDetail] Contest deleted, redirecting...') window.location.href = '/contests' - }) + } + + socket.on('connect', handleConnect) + socket.on('contest:updated', handleUpdated) + socket.on('contest:deleted', handleDeleted) return () => { - socket.disconnect() + socket.off('connect', handleConnect) + socket.off('contest:updated', handleUpdated) + socket.off('contest:deleted', handleDeleted) } - }, [contestId, mutate]) + }, [socket, contestId, mutate]) useContestTimer(contest?.startTime, contest?.endTime) // pre-warm timer diff --git a/src/features/interview/InterviewShell.jsx b/src/features/interview/InterviewShell.jsx index 5f298dd8..affccc6b 100644 --- a/src/features/interview/InterviewShell.jsx +++ b/src/features/interview/InterviewShell.jsx @@ -1,7 +1,7 @@ 'use client' import React, { useState, useEffect, useRef, useCallback } from 'react' -import { io } from 'socket.io-client' +import { useSecureSocket } from '@/hooks/useSecureSocket' import { Loader2, Clock, @@ -412,6 +412,11 @@ export default function InterviewShell({ // ── Socket.IO connection ───────────────────────────────────────────────── const socketRef = useRef(null) + const { socket: hookSocket, isConnected: socketConnected } = useSecureSocket('/interview', { + sessionId, + scope: 'interview', + }) + // ── Rehydration ────────────────────────────────────────────────────────── useEffect(() => { const rehydrate = async () => { @@ -491,7 +496,6 @@ export default function InterviewShell({ if (!wsTokenState || !sessionId || isRehydrating) return let active = true - const fallbackSocketBaseUrl = `http://localhost:${process.env.NEXT_PUBLIC_SOCKET_PORT || '3002'}` // Define event handler callbacks const handleAiStreamChunk = ({ chunk, done, messageId, sequence, error: streamError }) => { @@ -511,9 +515,7 @@ export default function InterviewShell({ setIsAiTyping(true) setMessages((prev) => { // 1. Try to find message by ID - const existingIdx = messageId - ? prev.findIndex((m) => m.id === messageId) - : -1 + const existingIdx = messageId ? prev.findIndex((m) => m.id === messageId) : -1 if (existingIdx !== -1) { const newMsg = { @@ -688,150 +690,76 @@ export default function InterviewShell({ const lastSequenceRef = { current: 0 } const currentStreamingId = { current: null } - const initializeSocket = async () => { - try { - const res = await fetch('/api/auth/ws-token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId, scope: 'interview' }), - credentials: 'include', - }) - if (!active) return - - const data = await res.json() - if (!active) return - - if (data.enabled === false) { - console.log('[InterviewShell] Sockets disabled (serverless). Falling back to MockInterviewSocket.') - const mockSocket = new MockInterviewSocket(sessionId) - socketRef.current = mockSocket - - setConnectionStatus('connected') - - // Register listeners - mockSocket.on('interview:ai_stream_chunk', handleAiStreamChunk) - mockSocket.on('interview:phase_change', handlePhaseChange) - mockSocket.on('interview:session_terminal', handleSessionTerminal) - mockSocket.on('interview:run_result', handleRunResult) - mockSocket.on('interview:submission_result', handleSubmissionResult) - mockSocket.on('interview:ai_analysis', handleAiAnalysis) - mockSocket.on('interview:scorecard', handleScorecard) - mockSocket.on('interview:ended', handleEnded) - mockSocket.on('reconnect', handleReconnect) - - // Trigger mock connect - setTimeout(() => { - if (active) { - mockSocket.trigger('connect') - } - }, 100) - } else { - let socketUrl_ = data.socketUrl || process.env.NEXT_PUBLIC_SOCKET_URL || fallbackSocketBaseUrl - - // Localhost protocol sanitization - if (typeof window !== 'undefined') { - const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; - const isPageHttp = window.location.protocol === 'http:'; - if (isLocalhost && isPageHttp) { - socketUrl_ = socketUrl_.replace(/^https:\/\//i, 'http://'); - } - } - - console.log(`[InterviewShell] Connecting to real socket at: ${socketUrl_}/interview`) - const socket = io(`${socketUrl_}/interview`, { - auth: { token: data.wsToken || wsTokenState }, - reconnectionAttempts: 3, - }) - socketRef.current = socket + if (!hookSocket) return - socket.on('connect', () => { - if (!active) return - setConnectionStatus('connected') - socket.emit('interview:join') - toast.success('Connected to interview session') - }) + const socket = hookSocket + socketRef.current = socket - socket.on('disconnect', (reason) => { - if (!active) return - console.warn('[Socket] Disconnected:', reason) - setIsAiTyping(false) - if (reason === 'io server disconnect') { - setConnectionStatus('failed') - } else { - setConnectionStatus('disconnected') - } - }) + socket.on('connect', () => { + if (!active) return + setConnectionStatus('connected') + socket.emit('interview:join') + toast.success('Connected to interview session') + }) - socket.on('connect_error', (err) => { - if (!active) return - console.error('[Socket] Connection Error:', err) - setIsAiTyping(false) - setConnectionStatus('reconnecting') - }) + socket.on('disconnect', (reason) => { + if (!active) return + console.warn('[Socket] Disconnected:', reason) + setIsAiTyping(false) + if (reason === 'io server disconnect') { + setConnectionStatus('failed') + } else { + setConnectionStatus('disconnected') + } + }) - socket.on('reconnect_attempt', (attempt) => { - if (!active) return - console.log('[Socket] Reconnecting...', attempt) - setConnectionStatus('reconnecting') - }) + socket.on('connect_error', (err) => { + if (!active) return + console.error('[Socket] Connection Error:', err) + setIsAiTyping(false) + setConnectionStatus('reconnecting') + }) - socket.on('reconnect_failed', () => { - if (!active) return - setConnectionStatus('failed') - setError({ - type: 'CONNECTION_FAILED', - message: 'Lost connection to the interview server. Please check your internet.', - fatal: true, - }) - }) + socket.on('reconnect_attempt', (attempt) => { + if (!active) return + console.log('[Socket] Reconnecting...', attempt) + setConnectionStatus('reconnecting') + }) - // Register dynamic event listeners - socket.on('interview:ai_stream_chunk', handleAiStreamChunk) - socket.on('interview:phase_change', handlePhaseChange) - socket.on('interview:session_terminal', handleSessionTerminal) - socket.on('interview:run_result', handleRunResult) - socket.on('interview:submission_result', handleSubmissionResult) - socket.on('interview:ai_analysis', handleAiAnalysis) - socket.on('interview:scorecard', handleScorecard) - socket.on('interview:ended', handleEnded) - socket.on('reconnect', handleReconnect) - } - } catch (err) { - console.error('[InterviewShell] Socket initialization failed:', err) - if (active) { - setError({ - type: 'CONNECTION_FAILED', - message: 'Failed to establish connection to the interview server.', - fatal: true, - }) - } - } - } + socket.on('reconnect_failed', () => { + if (!active) return + setConnectionStatus('failed') + setError({ + type: 'CONNECTION_FAILED', + message: 'Lost connection to the interview server. Please check your internet.', + fatal: true, + }) + }) - initializeSocket() + // Register dynamic event listeners + socket.on('interview:ai_stream_chunk', handleAiStreamChunk) + socket.on('interview:phase_change', handlePhaseChange) + socket.on('interview:session_terminal', handleSessionTerminal) + socket.on('interview:run_result', handleRunResult) + socket.on('interview:submission_result', handleSubmissionResult) + socket.on('interview:ai_analysis', handleAiAnalysis) + socket.on('interview:scorecard', handleScorecard) + socket.on('interview:ended', handleEnded) + socket.on('reconnect', handleReconnect) return () => { active = false - if (socketRef.current) { - socketRef.current.off('connect') - socketRef.current.off('disconnect') - socketRef.current.off('connect_error') - socketRef.current.off('reconnect_attempt') - socketRef.current.off('reconnect_failed') - socketRef.current.off('reconnect') - socketRef.current.off('interview:ai_stream_chunk') - socketRef.current.off('interview:run_result') - socketRef.current.off('interview:submission_result') - socketRef.current.off('interview:ai_analysis') - socketRef.current.off('interview:scorecard') - socketRef.current.off('interview:ended') - socketRef.current.off('interview:phase_change') - socketRef.current.off('interview:session_terminal') - socketRef.current.disconnect() - socketRef.current = null - } } - }, [wsToken, sessionId, onEnd, isRehydrating]) + }, [wsToken, sessionId, onEnd, isRehydrating, hookSocket]) + + // Sync socket connection status + useEffect(() => { + if (!hookSocket) return + socketRef.current = hookSocket + if (socketConnected) { + setConnectionStatus('connected') + } + }, [hookSocket, socketConnected]) // ── Hard timeout for stuck AI streaming ────────────────────────────────── const MAX_STREAM_TIME = 45_000 // 45 seconds to allow for high-load cold-starts diff --git a/src/features/interview/ScorecardView.jsx b/src/features/interview/ScorecardView.jsx index 9ab1218d..18c0646a 100644 --- a/src/features/interview/ScorecardView.jsx +++ b/src/features/interview/ScorecardView.jsx @@ -1,7 +1,7 @@ 'use client' -import React, { useEffect, useState, useRef } from 'react' -import { io } from 'socket.io-client' +import React, { useEffect, useState } from 'react' +import { useSecureSocket } from '@/hooks/useSecureSocket' import { Award, Download, @@ -24,8 +24,10 @@ export default function ScorecardView({ sessionId }) { const [result, setResult] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [isSocketConnected, setIsSocketConnected] = useState(false) - const socketRef = useRef(null) + const { socket, isConnected: socketConnected } = useSecureSocket('/interview', { + sessionId, + scope: 'interview', + }) useEffect(() => { // Use a ref to track result so fallbackPoll doesn't use a stale closure @@ -67,46 +69,6 @@ export default function ScorecardView({ sessionId }) { } } - const setupSocket = async () => { - try { - const res = await fetch(`/api/interview/sessions/${sessionId}/rehydrate`) - const json = await res.json() - if (!json.success || isUnmounted) return - - const fallbackSocketBaseUrl = `http://localhost:${process.env.NEXT_PUBLIC_SOCKET_PORT || '3002'}` - - const socket = io( - `${process.env.NEXT_PUBLIC_SOCKET_URL || fallbackSocketBaseUrl}/interview`, - { - auth: { token: json.data.wsToken }, - reconnectionAttempts: 5, - } - ) - socketRef.current = socket - - socket.on('connect', async () => { - setIsSocketConnected(true) - socket.emit('interview:join') - // ✅ Bug 4 Fix: immediately fetch on connect. - // For already-completed sessions the scorecard socket event - // will never replay — so we fetch right away on connection. - if (!resultFoundRef.current) { - await fetchResult() - } - }) - - socket.on('disconnect', () => setIsSocketConnected(false)) - - socket.on('interview:scorecard', async () => { - if (isUnmounted) return - console.log('[ScorecardView] Received push update via socket') - await fetchResult() - }) - } catch (err) { - console.error('[ScorecardView] Socket setup failed:', err) - } - } - const runLogic = async () => { const found = await fetchResult() if (found || isUnmounted) return @@ -121,9 +83,6 @@ export default function ScorecardView({ sessionId }) { } }, 60_000) - // Set up socket for live push - await setupSocket() - // ✅ Bug 3 Fix: poll every 3s instead of 15s // ✅ Bug 2 Fix: use resultFoundRef instead of stale `result` closure const fallbackPoll = async () => { @@ -146,12 +105,34 @@ export default function ScorecardView({ sessionId }) { return () => { isUnmounted = true clearTimeout(pollTimer) - if (socketRef.current) { - socketRef.current.disconnect() - } } }, [sessionId]) + // Socket event listeners + useEffect(() => { + if (!socket) return + + const handleScorecard = async () => { + console.log('[ScorecardView] Received push update via socket') + try { + const res = await fetch(`/api/interview/sessions/${sessionId}/result`) + const json = await res.json() + if (json.success && json.status !== 'pending') { + setResult(json.data) + setLoading(false) + setError(null) + } + } catch {} + } + + socket.on('interview:scorecard', handleScorecard) + socket.emit('interview:join') + + return () => { + socket.off('interview:scorecard', handleScorecard) + } + }, [socket, sessionId]) + const exportMarkdown = () => { window.location.href = `/api/interview/sessions/${sessionId}/export` } @@ -221,11 +202,9 @@ export default function ScorecardView({ sessionId }) { {/* Connection Status Indicator */}
- {isSocketConnected - ? 'LIVE UPDATES ACTIVE' - : 'CONNECTING TO REAL-TIME SERVER...'} + {socketConnected ? 'LIVE UPDATES ACTIVE' : 'CONNECTING TO REAL-TIME SERVER...'}
) diff --git a/src/features/problem-solve/components/SubmissionResultTab.jsx b/src/features/problem-solve/components/SubmissionResultTab.jsx index ac4bc7c5..9ac609b0 100644 --- a/src/features/problem-solve/components/SubmissionResultTab.jsx +++ b/src/features/problem-solve/components/SubmissionResultTab.jsx @@ -147,9 +147,14 @@ export default function SubmissionResultTab() { viewSubmissionDetails, } = useProblemSolve() const { user } = useAuth() + const [mounted, setMounted] = useState(false) const [copied, setCopied] = useState(false) const restoreAttemptedRef = useRef(false) + useEffect(() => { + setMounted(true) + }, []) + useEffect(() => { if (result || restoreAttemptedRef.current) return @@ -165,7 +170,7 @@ export default function SubmissionResultTab() { }) }, [result, viewSubmissionDetails]) - if (!result) { + if (!mounted || !result) { return (
diff --git a/src/lib/auth/wsToken.js b/src/lib/auth/wsToken.js deleted file mode 100644 index bf81829f..00000000 --- a/src/lib/auth/wsToken.js +++ /dev/null @@ -1,29 +0,0 @@ -import jwt from 'jsonwebtoken' - -const JWT_SECRET = process.env.JWT_SECRET - -function checkSecret() { - if (!JWT_SECRET && process.env.NEXT_PHASE !== 'phase-production-build') { - throw new Error('Please define JWT_SECRET in .env.local') - } -} - -/** - * Generates a short-lived token for WebSocket authentication. - * @param {Object} payload - Data to encode in the token (e.g., userId, sessionId) - * @returns {string} - Signed JWT valid for 15 minutes - */ -export function signWsToken(payload) { - checkSecret() - return jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }) -} - -/** - * Verifies a WebSocket token. - * @param {string} token - * @returns {Object} - Decoded payload - */ -export function verifyWsToken(token) { - checkSecret() - return jwt.verify(token, JWT_SECRET) -} diff --git a/src/lib/socket-server.js b/src/lib/socket-server.js index c7b6550b..de7fe59c 100644 --- a/src/lib/socket-server.js +++ b/src/lib/socket-server.js @@ -109,26 +109,34 @@ export async function initSocketServer() { // Setup Redis adapter when available, but keep Socket.IO online without it. let adapterEnabled = false - const pubClient = redisClient.duplicate() - const subClient = redisClient.duplicate() + let pubClient = null + let subClient = null try { - const [pubReady, subReady] = await Promise.all([ - safeConnectRedisClient(pubClient, 'Redis adapter pubClient'), - safeConnectRedisClient(subClient, 'Redis adapter subClient'), - ]) - - if (pubReady && subReady) { - serverIo.adapter(createAdapter(pubClient, subClient)) - adapterEnabled = true + pubClient = redisClient?.duplicate() + subClient = redisClient?.duplicate() + if (!pubClient || !subClient) { + console.warn('[Socket.IO] Redis client unavailable, running without adapter') } else { - await Promise.all([ - safeQuitRedisClient(pubClient), - safeQuitRedisClient(subClient), + const [pubReady, subReady] = await Promise.all([ + safeConnectRedisClient(pubClient, 'Redis adapter pubClient'), + safeConnectRedisClient(subClient, 'Redis adapter subClient'), ]) - console.warn('[Socket.IO] Redis adapter disabled, running in single-node mode') + if (pubReady && subReady) { + serverIo.adapter(createAdapter(pubClient, subClient)) + adapterEnabled = true + } else { + await Promise.all([ + safeQuitRedisClient(pubClient), + safeQuitRedisClient(subClient), + ]) + console.warn( + '[Socket.IO] Redis adapter disabled, running in single-node mode' + ) + } } } catch (adapterError) { - await Promise.all([safeQuitRedisClient(pubClient), safeQuitRedisClient(subClient)]) + if (pubClient) safeQuitRedisClient(pubClient) + if (subClient) safeQuitRedisClient(subClient) console.warn( `[Socket.IO] Redis adapter setup failed, continuing without adapter: ${adapterError.message}` ) diff --git a/src/lib/ws-token.js b/src/lib/ws-token.js index 2f181478..e9f8ca6f 100644 --- a/src/lib/ws-token.js +++ b/src/lib/ws-token.js @@ -8,6 +8,19 @@ const JWT_SECRET = process.env.JWT_SECRET const WS_TOKEN_EXPIRY = '1h' // WebSocket tokens live 1 hour const REFRESH_THRESHOLD = 5 * 60 * 1000 // Refresh token if < 5 min left +/** + * Generates a short-lived token for WebSocket authentication. + * Required payload keys: { userId, sessionId } + * @param {Object} payload — must include userId and sessionId + * @returns {string} Signed JWT valid for 15 minutes with type: 'websocket' + */ +export function signWsToken(payload) { + if (!JWT_SECRET) { + throw new Error('JWT_SECRET environment variable is required') + } + return jwt.sign({ ...payload, type: 'websocket' }, JWT_SECRET, { expiresIn: '15m' }) +} + export function generateWsToken(userId, sessionData = {}) { if (!JWT_SECRET) { throw new Error('JWT_SECRET environment variable is required') diff --git a/src/socket/namespaces/interview.js b/src/socket/namespaces/interview.js index ab40ecf5..ebd636ed 100644 --- a/src/socket/namespaces/interview.js +++ b/src/socket/namespaces/interview.js @@ -1,5 +1,5 @@ import crypto from 'crypto' -import { verifyWsToken } from '@/lib/auth/wsToken' +import { verifyWsToken } from '@/lib/ws-token' import { InterviewSession } from '@/models/InterviewSession.model' import { InterviewMessage } from '@/models/InterviewMessage.model' import { InterviewSnapshot } from '@/models/InterviewSnapshot.model' diff --git a/src/socket/namespaces/voice.js b/src/socket/namespaces/voice.js index 0f21cee7..bf40ae94 100644 --- a/src/socket/namespaces/voice.js +++ b/src/socket/namespaces/voice.js @@ -1,5 +1,5 @@ import { DeepgramClient } from '@deepgram/sdk' -import { verifyWsToken } from '@/lib/auth/wsToken' +import { verifyWsToken } from '@/lib/ws-token' import { isSessionActive } from '@/services/sessionGuard' import { hasVoiceAccess } from '@/services/accessControl.service' From b1a284662f908c7b3757d87a15ac1c0e175f42a1 Mon Sep 17 00:00:00 2001 From: mdadeel Date: Sun, 24 May 2026 20:19:51 +0600 Subject: [PATCH 2/2] refactor: enhance UI accessibility, improve socket connection reliability, and update landing page timeline --- src/app/blog/[slug]/page.jsx | 2 +- src/app/blog/page.jsx | 2 + src/app/contests/[id]/result/page.jsx | 2 +- src/app/guidelines/page.jsx | 2 +- src/app/help/page.jsx | 1 + src/app/layout.js | 2 + src/app/profile/settings/page.jsx | 2 +- src/components/layout/NotificationBell.jsx | 2 +- src/components/reactions/ReactionSystem.jsx | 2 +- src/components/ui/ScrollRevealCard.tsx | 2 +- .../home/components/DashboardLeftSidebar.jsx | 1 + .../home/components/FeaturesSection.jsx | 2 +- .../home/components/HowItWorksSection.jsx | 107 ++++++++---------- .../home/components/QuickStatsCard.jsx | 1 + src/features/interview/InterviewShell.jsx | 4 +- src/features/interview/ReplayPlayer.jsx | 1 + src/features/interview/ScorecardView.jsx | 4 +- .../interview/components/chat/AiChatPanel.jsx | 4 +- .../interview/context/InterviewContext.jsx | 4 +- .../components/WorkspaceToolbar.jsx | 2 +- .../problems/components/ProblemsToolbar.jsx | 1 + src/hooks/useSecureSocket.js | 71 +++++++----- src/lib/socket-server.js | 4 + src/lib/ws-token.js | 2 +- src/shared/components/ui/DevNoticeModal.jsx | 74 ++++++++++++ 25 files changed, 198 insertions(+), 103 deletions(-) create mode 100644 src/shared/components/ui/DevNoticeModal.jsx diff --git a/src/app/blog/[slug]/page.jsx b/src/app/blog/[slug]/page.jsx index 6645211b..5e2eb26a 100644 --- a/src/app/blog/[slug]/page.jsx +++ b/src/app/blog/[slug]/page.jsx @@ -142,7 +142,7 @@ export default function BlogDetailPage() { if (!post) { return (
-

Post not found

+

Post not found

Return to Arena Journal diff --git a/src/app/blog/page.jsx b/src/app/blog/page.jsx index afaea9a1..c40cc15f 100644 --- a/src/app/blog/page.jsx +++ b/src/app/blog/page.jsx @@ -130,6 +130,7 @@ export default function BlogListingPage() { placeholder="Search articles..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} + aria-label="Search articles" className="bg-bg-subtle border-border text-text-primary placeholder:text-text-muted focus:ring-accent w-full rounded-md border py-2 pr-4 pl-10 text-sm transition-all focus:ring-2 focus:outline-none" />
@@ -170,6 +171,7 @@ export default function BlogListingPage() { diff --git a/src/features/problems/components/ProblemsToolbar.jsx b/src/features/problems/components/ProblemsToolbar.jsx index c1627f4c..0627e7c5 100644 --- a/src/features/problems/components/ProblemsToolbar.jsx +++ b/src/features/problems/components/ProblemsToolbar.jsx @@ -40,6 +40,7 @@ export default function ProblemsToolbar({ sortBy, setSortBy, setSidebarOpen, tot value={sortBy} onChange={(e) => setSortBy(e.target.value)} className="bg-bg-page border-border text-text-primary focus:ring-accent duration-normal cursor-pointer rounded-md border px-3 py-2 pr-8 text-sm transition-colors focus:border-transparent focus:ring-2 focus:outline-none" + aria-label="Sort by" > diff --git a/src/hooks/useSecureSocket.js b/src/hooks/useSecureSocket.js index 81dc012a..78dbf64d 100644 --- a/src/hooks/useSecureSocket.js +++ b/src/hooks/useSecureSocket.js @@ -31,6 +31,7 @@ export function useSecureSocket(namespace = '', options = {}) { const tokenRequestRef = useRef(null) const connectingRef = useRef(false) const connectRef = useRef(null) + const connectGenRef = useRef(0) const { onConnect, @@ -50,28 +51,29 @@ export function useSecureSocket(namespace = '', options = {}) { } try { - tokenRequestRef.current = fetch('/api/auth/ws-token', { + const promise = fetch('/api/auth/ws-token', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ sessionId, scope }), credentials: 'include', - }) - - const response = await tokenRequestRef.current + }).then(async (response) => { + if (!response.ok) { + throw new Error(`Token fetch failed: ${response.status}`) + } - if (!response.ok) { - throw new Error(`Token fetch failed: ${response.status}`) - } + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Token generation failed') + } - const data = await response.json() - if (!data.success) { - throw new Error(data.error || 'Token generation failed') - } + tokenRef.current = data.wsToken + return data + }) - tokenRef.current = data.wsToken - return data + tokenRequestRef.current = promise + return await promise } catch (err) { console.error('[useSecureSocket] Token fetch error:', err.message) setError(err.message) @@ -91,10 +93,7 @@ export function useSecureSocket(namespace = '', options = {}) { return } - if (connectingRef.current) { - console.log('[useSecureSocket] Connection already in progress') - return - } + const gen = ++connectGenRef.current if (socketRef.current?.connected) { console.log('[useSecureSocket] Already connected') @@ -108,6 +107,8 @@ export function useSecureSocket(namespace = '', options = {}) { const data = await fetchWsToken() // console.log("Socket Console Data: ", data); + if (gen !== connectGenRef.current) return + if (data && data.enabled === false) { console.log( `[useSecureSocket] Real-time sockets are disabled on the server (serverless mode). Bypassing connection to namespace: ${namespace}` @@ -118,18 +119,23 @@ export function useSecureSocket(namespace = '', options = {}) { return } - const { wsToken, socketUrl } = data; + const { wsToken, socketUrl } = data + if (!wsToken) { + throw new Error('WebSocket token is empty') + } //console.log("Socket Console: ", socketUrl); - let socketUrl_ = process.env.NEXT_PUBLIC_SOCKET_URL || socketUrl; + let socketUrl_ = process.env.NEXT_PUBLIC_SOCKET_URL || socketUrl // Localhost protocol sanitization if (typeof window !== 'undefined') { - const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; - const isPageHttp = window.location.protocol === 'http:'; - + const isLocalhost = + window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1' + const isPageHttp = window.location.protocol === 'http:' + if (isLocalhost && isPageHttp && socketUrl_) { - socketUrl_ = socketUrl_.replace(/^https:\/\//i, 'http://'); + socketUrl_ = socketUrl_.replace(/^https:\/\//i, 'http://') } } @@ -198,7 +204,9 @@ export function useSecureSocket(namespace = '', options = {}) { console.warn('[useSecureSocket] Connection failed:', err.message) setError(err.message) } finally { - connectingRef.current = false + if (gen === connectGenRef.current) { + connectingRef.current = false + } } }, [user, namespace, fetchWsToken, onConnect, onDisconnect, onError, autoReconnect]) @@ -221,10 +229,15 @@ export function useSecureSocket(namespace = '', options = {}) { const { wsToken } = await fetchWsToken() tokenRef.current = wsToken - // Reconnect with new token if needed - if (socketRef.current?.disconnected) { - await connect() + // Force reconnect to apply the updated auth token + if (socketRef.current?.connected) { + socketRef.current.removeAllListeners() + socketRef.current.disconnect() + socketRef.current = null + setSocket(null) + setIsConnected(false) } + await connect() } catch (err) { console.error('[useSecureSocket] Token refresh failed:', err.message) // Token refresh failed, will attempt on next connect @@ -251,6 +264,9 @@ export function useSecureSocket(namespace = '', options = {}) { connectRef.current?.() return () => { + // Increment generation to invalidate any in-flight connect call + connectGenRef.current++ + // Cleanup timeout if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current) @@ -262,7 +278,6 @@ export function useSecureSocket(namespace = '', options = {}) { socketRef.current = null setSocket(null) setIsConnected(false) - connectingRef.current = false } }, [user]) diff --git a/src/lib/socket-server.js b/src/lib/socket-server.js index de7fe59c..ae49f2ef 100644 --- a/src/lib/socket-server.js +++ b/src/lib/socket-server.js @@ -221,6 +221,10 @@ export async function initSocketServer() { // Redis Subscriber for submission events try { + if (!redisClient) { + console.warn('[Socket.IO] Redis unavailable, skipping subscriber setup') + throw new Error('Redis client unavailable') + } const redisSubClient = redisClient.duplicate() await redisSubClient.connect() diff --git a/src/lib/ws-token.js b/src/lib/ws-token.js index e9f8ca6f..f5a62aca 100644 --- a/src/lib/ws-token.js +++ b/src/lib/ws-token.js @@ -18,7 +18,7 @@ export function signWsToken(payload) { if (!JWT_SECRET) { throw new Error('JWT_SECRET environment variable is required') } - return jwt.sign({ ...payload, type: 'websocket' }, JWT_SECRET, { expiresIn: '15m' }) + return jwt.sign({ ...payload, type: 'websocket' }, JWT_SECRET, { expiresIn: WS_TOKEN_EXPIRY }) } export function generateWsToken(userId, sessionData = {}) { diff --git a/src/shared/components/ui/DevNoticeModal.jsx b/src/shared/components/ui/DevNoticeModal.jsx new file mode 100644 index 00000000..9e94342b --- /dev/null +++ b/src/shared/components/ui/DevNoticeModal.jsx @@ -0,0 +1,74 @@ +'use client' + +import { useEffect, useState } from 'react' +import { X } from 'lucide-react' + +export default function DevNoticeModal() { + const [open, setOpen] = useState(false) + + useEffect(() => { + const stored = localStorage.getItem('dev_notice_dismissed') + if (stored === 'true') return + + const isDeployEnv = + window.location.hostname.includes('vercel') || + window.location.hostname.includes('now.sh') || + window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1' || + process.env.NEXT_PUBLIC_VERCEL_ENV + + if (!isDeployEnv) return + + const timer = setTimeout(() => setOpen(true), 600) + return () => clearTimeout(timer) + }, []) + + const dismiss = () => { + setOpen(false) + try { + localStorage.setItem('dev_notice_dismissed', 'true') + } catch {} + } + + if (!open) return null + + return ( +
+
+
+

Development Notice

+ +
+ +
+

+ This instance of CodeArena is running on{' '} + Vercel's free tier{' '} + and is currently under active development. +

+

+ As a result, certain features including real-time collaboration, + WebSocket connections, and code execution may be limited or behave + differently than they would in a full production environment. +

+

+ We're working hard to bring the complete experience. Thank you for + your understanding and support. +

+
+ + +
+
+ ) +}