diff --git a/src/frontend/package.json b/src/frontend/package.json index e47a2963..ad42a1bd 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -44,6 +44,7 @@ "devDependencies": { "@chromatic-com/storybook": "^3.2.4", "@eslint/js": "^9.17.0", + "@mswjs/socket.io-binding": "^0.1.1", "@storybook/addon-essentials": "^8.5.0", "@storybook/addon-interactions": "^8.5.0", "@storybook/addon-onboarding": "^8.5.0", diff --git a/src/frontend/src/__mock__/handlers/chatting.handler.ts b/src/frontend/src/__mock__/handlers/chatting.handler.ts new file mode 100644 index 00000000..a75b8ab4 --- /dev/null +++ b/src/frontend/src/__mock__/handlers/chatting.handler.ts @@ -0,0 +1,18 @@ +import { WebSocketLink, ws } from 'msw' + +import { CHAT_SERVER_URL } from '@/constants/env' +import { log } from '@/utils/log' + +const chattingServer: WebSocketLink = ws.link(CHAT_SERVER_URL) + +export const chattingHandlers = [ + chattingServer.addEventListener('connection', ({ client }) => { + log('✅ WebSocket connection initiated') + + // 클라이언트 메시지를 서버로 전달 + client.addEventListener('message', (event) => { + // 연결 시도만 하고 실제 전송은 하지 않음 + log('Message from client:', event.data) + }) + }) +] diff --git a/src/frontend/src/__mock__/handlers/signaling.handler.ts b/src/frontend/src/__mock__/handlers/signaling.handler.ts new file mode 100644 index 00000000..64858bf3 --- /dev/null +++ b/src/frontend/src/__mock__/handlers/signaling.handler.ts @@ -0,0 +1,64 @@ +import { toSocketIo } from '@mswjs/socket.io-binding' +import { WebSocketLink, ws } from 'msw' + +import { SIGNALING_NODE_SERVER_URL } from '@/constants/env' +import { log } from '@/utils/log' + +const signalingServer: WebSocketLink = ws.link( + `${SIGNALING_NODE_SERVER_URL}/socket.io/?EIO=4&transport=websocket` +) + +export const signalingHandlers = [ + signalingServer.addEventListener('connection', (connection) => { + log('✅ WebSocket connection initiated') + + const io = toSocketIo(connection) + const { client } = io + const mockSocketId = 'mock-' + Math.random().toString(36).substring(2, 9) + let roomName: string + let userId: string + + // 클라이언트 메시지를 서버로 전달 + connection.client.addEventListener('message', (event) => { + // 연결 시도만 하고 실제 전송은 하지 않음 + log('Message from client:', event.data) + }) + + // 클라이언트 이벤트 처리 + client.on('join_room', (_, data: unknown) => { + try { + const { roomName: room, userId: user } = data as { roomName: string; userId: string } + roomName = room + userId = user + log(`✅ User ${userId} joined room ${roomName}`) + client.emit('welcome', mockSocketId, userId) + } catch (e) { + log('Error in join_room:', e) + } + }) + + // 나머지 이벤트 핸들러들... + client.on('offer', (_, offer, remoteId) => { + log('Received offer for:', remoteId) + client.emit('offer', offer, mockSocketId, userId) + }) + + client.on('answer', (_, answer, remoteId) => { + log('Received answer for:', remoteId) + client.emit('answer', answer, mockSocketId, userId) + }) + + client.on('ice', (_, ice, remoteId) => { + log('Received ICE candidate for:', remoteId) + client.emit('ice', ice, mockSocketId, userId) + }) + + client.on('disconnect', () => { + log('❌ Client disconnected:', mockSocketId) + client.emit('user_left', mockSocketId) + }) + + // 초기 handshake 응답 + client.emit('connect') + }) +] diff --git a/src/frontend/src/__mock__/worker.ts b/src/frontend/src/__mock__/worker.ts index 947f6d29..c2f731f1 100644 --- a/src/frontend/src/__mock__/worker.ts +++ b/src/frontend/src/__mock__/worker.ts @@ -1,13 +1,17 @@ import { setupWorker } from 'msw/browser' import { authHandler } from './handlers/auth.handler' +import { chattingHandlers } from './handlers/chatting.handler' import { searchHandler } from './handlers/search.handler' import { serviceHandler } from './handlers/service.handler' +import { signalingHandlers } from './handlers/signaling.handler' import { userHandler } from './handlers/user.handler' export const worker = setupWorker( ...authHandler, ...serviceHandler, ...userHandler, - ...searchHandler + ...searchHandler, + ...signalingHandlers, + ...chattingHandlers ) diff --git a/src/frontend/src/hooks/use-singaling-with-mesh.ts b/src/frontend/src/hooks/use-singaling.ts similarity index 84% rename from src/frontend/src/hooks/use-singaling-with-mesh.ts rename to src/frontend/src/hooks/use-singaling.ts index a0716aea..97643034 100644 --- a/src/frontend/src/hooks/use-singaling-with-mesh.ts +++ b/src/frontend/src/hooks/use-singaling.ts @@ -1,17 +1,14 @@ import { useEffect, useRef } from 'react' -import io, { Socket } from 'socket.io-client' -import { SIGNALING_NODE_SERVER_URL, TURN_SERVER_URL } from '@/constants/env' +import { TURN_SERVER_URL } from '@/constants/env' +import { useSignalingSocket } from '@/stores/use-signaling-socket' import { useUserStatus } from '@/stores/use-user-status-store' import useGetSelfUser from './queries/user/useGetSelfUser' -export function useSingalingWithMeshSocket( - channelId: number, - channelName: string, - serverName: string -) { +export function useSingaling() { const { getCurrentChannelInfo, joinVoiceChannel, leaveVoiceChannel } = useUserStatus() + const { on, off, emit } = useSignalingSocket() const selfUser = useGetSelfUser() // 레퍼런스 @@ -20,13 +17,8 @@ export function useSingalingWithMeshSocket( const myStreamRef = useRef(null) // 내 로컬 미디어 스트림 const peersRef = useRef>({}) // socketId -> RTCPeerConnection const userMapRef = useRef>({}) // socketId -> userId (상대방 표시용) - const socket = useRef( - io(SIGNALING_NODE_SERVER_URL, { - // path: '/socket.io', - // transports: ['websocket'], - withCredentials: true - }) - ).current + + const { channelId } = getCurrentChannelInfo() ?? {} // ----------------------------- // (1) 미디어 스트림 획득 @@ -85,12 +77,12 @@ export function useSingalingWithMeshSocket( } } - socket.on('user_left', handleUserLeft) + on('user_left', handleUserLeft) return () => { - socket.off('user_left', handleUserLeft) + off('user_left', handleUserLeft) } - }, [socket]) + }, [on, off]) // ----------------------------- // (4) 소켓 이벤트: welcome/offer/answer/ice @@ -118,7 +110,7 @@ export function useSingalingWithMeshSocket( // ICE candidate pc.addEventListener('icecandidate', (event) => { if (event.candidate) { - socket.emit('ice', event.candidate, socketId) + emit('ice', event.candidate, socketId) } }) @@ -180,7 +172,7 @@ export function useSingalingWithMeshSocket( await peersRef.current[newSocketId].setLocalDescription(offer) // 서버에 offer 전송 - socket.emit('offer', offer, newSocketId) + emit('offer', offer, newSocketId) } // offer 수신 (내가 나중에 들어왔을 때) @@ -203,7 +195,7 @@ export function useSingalingWithMeshSocket( const answer = await peersRef.current[remoteId].createAnswer() await peersRef.current[remoteId].setLocalDescription(answer) - socket.emit('answer', answer, remoteId) + emit('answer', answer, remoteId) } // answer 수신 @@ -232,18 +224,18 @@ export function useSingalingWithMeshSocket( } } - socket.on('welcome', handleWelcome) - socket.on('offer', handleOffer) - socket.on('answer', handleAnswer) - socket.on('ice', handleIce) + on('welcome', handleWelcome) + on('offer', handleOffer) + on('answer', handleAnswer) + on('ice', handleIce) return () => { - socket.off('welcome', handleWelcome) - socket.off('offer', handleOffer) - socket.off('answer', handleAnswer) - socket.off('ice', handleIce) + off('welcome', handleWelcome) + off('offer', handleOffer) + off('answer', handleAnswer) + off('ice', handleIce) } - }, [socket]) + }, [on, off, emit]) // ----------------------------- // (5) 방 입장 @@ -256,9 +248,15 @@ export function useSingalingWithMeshSocket( } }, [channelId, getCurrentChannelInfo]) - function joinChannel() { - socket.connect() - + function joinChannel({ + channelId, + channelName, + serverName + }: { + channelId: number + channelName: string + serverName: string + }) { getMedia() joinVoiceChannel({ @@ -267,7 +265,7 @@ export function useSingalingWithMeshSocket( serverName }) - socket.emit('join_room', { roomName: channelId, userId: selfUser.id }) + emit('join_room', { roomName: channelId, userId: selfUser.id }) } function leaveChannel() { @@ -276,8 +274,6 @@ export function useSingalingWithMeshSocket( myStreamRef.current.getTracks().forEach((track) => track.stop()) myStreamRef.current = null } - // 소켓 해제 - socket.disconnect() // 내 비디오 제거 if (myFaceRef.current) { @@ -292,16 +288,7 @@ export function useSingalingWithMeshSocket( leaveVoiceChannel() } - // ----------------------------- - // (10) 페이지 떠날 때 소켓 해제 - // ----------------------------- - useEffect(() => { - return function cleanup() { - socket.disconnect() - } - }, [socket]) - - const isInVoiceChannel = getCurrentChannelInfo()?.channelId === channelId + const isInVoiceChannel = getCurrentChannelInfo() return { callRef, diff --git a/src/frontend/src/layouts/main-layout/index.tsx b/src/frontend/src/layouts/main-layout/index.tsx index b72d8e88..a6492e5b 100644 --- a/src/frontend/src/layouts/main-layout/index.tsx +++ b/src/frontend/src/layouts/main-layout/index.tsx @@ -12,7 +12,7 @@ import useGetSelfUser from '@/hooks/queries/user/useGetSelfUser' import { useChattingStomp } from '@/hooks/use-chatting-stomp' import { useMediaSettingsStore } from '@/stores/use-media-setting.store' import { useServerUnreadStore } from '@/stores/use-server-unread-store' -import { useSignalingStomp } from '@/stores/use-signaling-stomp-store' +import { useSignalingSocket } from '@/stores/use-signaling-socket' import ProfileCard from './components/profile-card' import ProfileStatusButton from './components/profile-status-button' @@ -20,6 +20,7 @@ import ServerCreateModal from './components/server-create-modal' import { ServerList } from './components/server-list' import { ServerListSkeleton } from './components/server-list/server-list-skeleton' import SettingModal, { SettingModalTabsID } from './components/setting-modal' + const Inner = () => { const { connect: connectChatting, @@ -29,7 +30,7 @@ const Inner = () => { unsubscribe, checkConnection } = useChattingStomp() - const { connect: connectSignaling, disconnect: disconnectSignaling } = useSignalingStomp() + const { connect: connectSignaling, disconnect: disconnectSignaling } = useSignalingSocket() const { serverId } = useParams<{ serverId: string }>() const previousServerId = useRef(null) diff --git a/src/frontend/src/layouts/root-layout.tsx b/src/frontend/src/layouts/root-layout.tsx index d062b72c..06e9c72f 100644 --- a/src/frontend/src/layouts/root-layout.tsx +++ b/src/frontend/src/layouts/root-layout.tsx @@ -4,13 +4,11 @@ import { QueryClientProvider } from '@tanstack/react-query' import { Toaster } from 'react-hot-toast' import { Outlet } from 'react-router-dom' -import MSWProvider from '@/libs/msw' import queryClient from '@/libs/query-client' function RootLayout() { return (
Biscord - diff --git a/src/frontend/src/libs/msw.tsx b/src/frontend/src/libs/msw.tsx deleted file mode 100644 index 3374e918..00000000 --- a/src/frontend/src/libs/msw.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect } from 'react' - -import { worker } from '@/__mock__/worker' -import { MOCK_SERVICE_WORKER } from '@/constants/env' - -function MSWProvider() { - useEffect(() => { - if (MOCK_SERVICE_WORKER) { - worker.start() - } - }, []) - - return null -} - -export default MSWProvider diff --git a/src/frontend/src/main.tsx b/src/frontend/src/main.tsx index 3e6fddaa..8848d603 100644 --- a/src/frontend/src/main.tsx +++ b/src/frontend/src/main.tsx @@ -1,8 +1,24 @@ +import { startTransition } from 'react' import { createRoot } from 'react-dom/client' import { RouterProvider } from 'react-router-dom' +import { MOCK_SERVICE_WORKER } from '@/constants/env' + import { router } from './routes' -createRoot(document.getElementById('root') as HTMLElement).render( - -) +async function enableMocking() { + if (MOCK_SERVICE_WORKER) { + const { worker } = await import('@/__mock__/worker') + worker.start({ + onUnhandledRequest: 'bypass' + }) + } +} + +enableMocking().then(() => { + startTransition(() => { + createRoot(document.getElementById('root') as HTMLElement).render( + + ) + }) +}) diff --git a/src/frontend/src/pages/channel/components/voice.tsx b/src/frontend/src/pages/channel/components/voice.tsx index db7c77af..cffde7d3 100644 --- a/src/frontend/src/pages/channel/components/voice.tsx +++ b/src/frontend/src/pages/channel/components/voice.tsx @@ -3,7 +3,7 @@ import toast from 'react-hot-toast' import ChatArea from '@/components/chat-area' import CustomButton from '@/components/custom-button' -import { useSingalingWithMeshSocket } from '@/hooks/use-singaling-with-mesh' +import { useSingaling } from '@/hooks/use-singaling' import { cn } from '@/libs/cn' import { ChatUser } from '@/types/user' @@ -26,14 +26,14 @@ function VideoComponent({ }: Props) { const [sideBar, setSideBar] = useState(false) - const { joinChannel, leaveChannel, isInVoiceChannel, callRef, myFaceRef } = - useSingalingWithMeshSocket(channelId, channelName, serverName) - - // const { joinChannel, leaveChannel, isInVoiceChannel, callRef, myFaceRef } = - // useSignalingWithSFU(channelId, channelName, serverName) + const { joinChannel, leaveChannel, isInVoiceChannel, callRef, myFaceRef } = useSingaling() const handleJoinVoiceChannel = () => { - joinChannel() + joinChannel({ + channelId, + channelName, + serverName + }) } const handleLeaveVoiceChannel = () => { diff --git a/src/frontend/src/stores/use-signaling-socket.ts b/src/frontend/src/stores/use-signaling-socket.ts new file mode 100644 index 00000000..fce9ae05 --- /dev/null +++ b/src/frontend/src/stores/use-signaling-socket.ts @@ -0,0 +1,57 @@ +import io, { Socket } from 'socket.io-client' +import { create } from 'zustand' + +import { SIGNALING_NODE_SERVER_URL } from '@/constants/env' +import { errorLog } from '@/utils/log' + +interface SignalingSocketStore { + socket: Socket | null + connect: () => void + disconnect: () => void + on: (event: string, callback: (...args: T) => void) => void + off: (event: string, callback: (...args: T) => void) => void + emit: (event: string, ...args: T) => void +} + +export const useSignalingSocket = create((set, get) => ({ + socket: null, + connect: () => { + const socket = io(SIGNALING_NODE_SERVER_URL, { + transports: ['websocket'], + forceNew: true, + autoConnect: true, + reconnectionAttempts: 3 + }) + + socket.on('connect_error', (error) => { + errorLog('Socket connection error:', error) + }) + + set({ socket }) + }, + disconnect: () => { + const socket = get().socket + if (socket) { + socket.disconnect() + set({ socket: null }) + } + }, + on: (event: string, callback: (...args: T) => void) => { + const socket = get().socket + if (socket) { + socket.on(event, callback) + } + }, + off: (event: string, callback: (...args: T) => void) => { + const socket = get().socket + if (socket) { + socket.off(event, callback) + } + }, + emit: (event: string, ...args: T) => { + const socket = get().socket + if (socket) { + socket.emit(event, ...args) + } + } +})) diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index 30763ba4..34333945 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -33,6 +33,9 @@ export default defineConfig(({ mode }) => { } }) ], + optimizeDeps: { + include: ['@mswjs/socket.io-binding'] + }, build: { outDir: 'build/client', emptyOutDir: true, diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index f9dfdbe5..f672593e 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -1248,7 +1248,7 @@ __metadata: languageName: node linkType: hard -"@mswjs/interceptors@npm:^0.37.0": +"@mswjs/interceptors@npm:^0.37.0, @mswjs/interceptors@npm:^0.37.1": version: 0.37.6 resolution: "@mswjs/interceptors@npm:0.37.6" dependencies: @@ -1262,6 +1262,19 @@ __metadata: languageName: node linkType: hard +"@mswjs/socket.io-binding@npm:^0.1.1": + version: 0.1.1 + resolution: "@mswjs/socket.io-binding@npm:0.1.1" + dependencies: + "@mswjs/interceptors": "npm:^0.37.1" + engine.io-parser: "npm:^5.2.3" + socket.io-parser: "npm:^4.2.4" + peerDependencies: + "@mswjs/interceptors": "*" + checksum: 10c0/fc433ebc9bc1eb7e66f5c19eb486c080b39b62d973d56f8624f94f4144cbc26abf445b9138d34ae892f4b629ca2b9d7165b483c39db800b0b7c59e519b10c1b6 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -3579,7 +3592,7 @@ __metadata: languageName: node linkType: hard -"engine.io-parser@npm:~5.2.1": +"engine.io-parser@npm:^5.2.3, engine.io-parser@npm:~5.2.1": version: 5.2.3 resolution: "engine.io-parser@npm:5.2.3" checksum: 10c0/ed4900d8dbef470ab3839ccf3bfa79ee518ea8277c7f1f2759e8c22a48f64e687ea5e474291394d0c94f84054749fd93f3ef0acb51fa2f5f234cc9d9d8e7c536 @@ -4362,6 +4375,7 @@ __metadata: "@chromatic-com/storybook": "npm:^3.2.4" "@eslint/js": "npm:^9.17.0" "@hookform/resolvers": "npm:^3.10.0" + "@mswjs/socket.io-binding": "npm:^0.1.1" "@sentry/react": "npm:^9.1.0" "@sentry/vite-plugin": "npm:^3.1.2" "@stomp/stompjs": "npm:^7.0.0" @@ -6941,7 +6955,7 @@ __metadata: languageName: node linkType: hard -"socket.io-parser@npm:~4.2.4": +"socket.io-parser@npm:^4.2.4, socket.io-parser@npm:~4.2.4": version: 4.2.4 resolution: "socket.io-parser@npm:4.2.4" dependencies: