| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- 'use client';
- import { createContext, useContext, useEffect, useState, useRef } from 'react';
- import * as signalR from '@microsoft/signalr';
- import { fetchApi } from '@/lib/utils/client';
- type FeedPostEvent = {
- postID: number;
- authorSID: string;
- authorName: string|null;
- authorThumb: string|null;
- subject: string;
- thumbnail: string|null;
- createdAt: string;
- };
- // Backend hub 매핑
- // chatConnection → /hubs/chat (ChatHub) — 채팅, 향후 1:1 실시간 문의
- // appConnection → /hubs/app (AppHub) — 알림, 쪽지, 피드 토스트
- const SignalRContext = createContext<{
- chatConnection: signalR.HubConnection|null;
- chatConnected: boolean;
- appConnection: signalR.HubConnection|null;
- appConnected: boolean;
- stopConnections: () => Promise<void>;
- reconnectChat: (accessToken?: string|null) => Promise<void>;
- reconnectApp: (accessToken?: string|null) => Promise<void>;
- lastFeedPost: FeedPostEvent|null;
- clearLastFeedPost: () => void;
- }>({
- chatConnection: null,
- chatConnected: false,
- appConnection: null,
- appConnected: false,
- stopConnections: async () => {},
- reconnectChat: async () => {},
- reconnectApp: async () => {},
- lastFeedPost: null,
- clearLastFeedPost: () => {}
- });
- type Props = {
- children: React.ReactNode;
- accessToken: string|null;
- signalRChatUrl: string;
- signalRAppUrl: string;
- }
- export function SignalRProvider({ children, accessToken, signalRChatUrl, signalRAppUrl }: Props) {
- const chatConnectionRef = useRef<signalR.HubConnection|null>(null);
- const appConnectionRef = useRef<signalR.HubConnection|null>(null);
- const [chatConnection, setChatConnection] = useState<signalR.HubConnection|null>(null);
- const [chatConnected, setChatConnected] = useState<boolean>(false);
- const [appConnection, setAppConnection] = useState<signalR.HubConnection|null>(null);
- const [appConnected, setAppConnected] = useState<boolean>(false);
- const [lastFeedPost, setLastFeedPost] = useState<FeedPostEvent|null>(null);
- // 초기 1회만 시작 — 토큰 갱신 시에는 reconnectChat / reconnectApp 으로 수동 재연결.
- // ⚡ 최초 페인트/하이드레이션 임계경로에서 빼기 위해 idle 로 지연(핸드셰이크 2건). 알림·쪽지·Kick 은 그대로 동작.
- useEffect(() => {
- let idleId: number|undefined;
- let timeoutId: ReturnType<typeof setTimeout>|undefined;
- const start = () => {
- initChatConnection(accessToken);
- initAppConnection(accessToken);
- };
- const w = window as typeof window & {
- requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
- cancelIdleCallback?: (id: number) => void;
- };
- if (typeof w.requestIdleCallback === 'function') {
- idleId = w.requestIdleCallback(start, { timeout: 2000 });
- } else {
- timeoutId = setTimeout(start, 800);
- }
- return () => {
- if (idleId !== undefined && typeof w.cancelIdleCallback === 'function') {
- w.cancelIdleCallback(idleId);
- }
- if (timeoutId !== undefined) {
- clearTimeout(timeoutId);
- }
- stopConnections();
- };
- }, []);
- useEffect(() => {
- if (chatConnected) {
- console.info('SignalR Chat Connected');
- }
- }, [chatConnected]);
- useEffect(() => {
- if (appConnected) {
- console.info('SignalR App Connected');
- }
- }, [appConnected]);
- const buildConn = (url: string, token: string|null|undefined) => {
- const options = token ? { accessTokenFactory: async () => token, withCredentials: true } : {};
- return new signalR.HubConnectionBuilder().withUrl(url, options).withAutomaticReconnect().build();
- };
- const initChatConnection = async (token?: string|null) => {
- if (!signalRChatUrl) {
- console.warn('SIGNALR_CHAT_URL not configured — chat hub disabled');
- return;
- }
- try {
- if (chatConnectionRef.current && chatConnectionRef.current.state !== signalR.HubConnectionState.Disconnected) {
- await chatConnectionRef.current.stop();
- }
- const conn = buildConn(signalRChatUrl, token);
- await conn.start();
- conn.on('Connected', (message) => {
- console.info('[chat]', message);
- });
- conn.on('Logout', (message) => {
- console.info('[chat]', message);
- });
- chatConnectionRef.current = conn;
- setChatConnection(conn);
- setChatConnected(true);
- } catch (error) {
- console.error('SignalR Chat Connect Failed:', error);
- }
- };
- const initAppConnection = async (token?: string|null) => {
- if (!signalRAppUrl) {
- console.warn('SIGNALR_APP_URL not configured — app hub disabled');
- return;
- }
- try {
- if (appConnectionRef.current && appConnectionRef.current.state !== signalR.HubConnectionState.Disconnected) {
- await appConnectionRef.current.stop();
- }
- const conn = buildConn(signalRAppUrl, token);
- await conn.start();
- conn.on('Connected', (message) => {
- console.info('[app]', message);
- });
- conn.on('Logout', (message) => {
- console.info('[app]', message);
- });
- // ── 피드 이벤트 ──
- conn.on('ReceiveFeedPost', (data: FeedPostEvent) => {
- setLastFeedPost(data);
- });
- // ── 강제 종료 ──
- conn.on('Kick', async () => {
- fetchApi('/api/auth/logout', {
- method: 'POST'
- }).then(() => {
- alert('관리자에 의해 강제 종료되었습니다.');
- localStorage.setItem('rememberMe', "false");
- localStorage.removeItem('member');
- location.replace('/');
- });
- });
- appConnectionRef.current = conn;
- setAppConnection(conn);
- setAppConnected(true);
- } catch (error) {
- console.error('SignalR App Connect Failed:', error);
- }
- };
- const stopConnections = async () => {
- if (chatConnectionRef.current && chatConnectionRef.current.state === signalR.HubConnectionState.Connected) {
- try {
- await chatConnectionRef.current.invoke('Logout');
- setChatConnected(false);
- } catch (error) {
- console.error('SignalR Chat Disconnect Failed:', error);
- }
- }
- if (appConnectionRef.current && appConnectionRef.current.state === signalR.HubConnectionState.Connected) {
- try {
- await appConnectionRef.current.invoke('Logout');
- setAppConnected(false);
- } catch (error) {
- console.error('SignalR App Disconnect Failed:', error);
- }
- }
- };
- const reconnectChat = async (token?: string|null) => {
- await initChatConnection(token);
- };
- const reconnectApp = async (token?: string|null) => {
- await initAppConnection(token);
- };
- return (
- <SignalRContext.Provider value={{
- chatConnection,
- chatConnected,
- appConnection,
- appConnected,
- stopConnections,
- reconnectChat,
- reconnectApp,
- lastFeedPost,
- clearLastFeedPost: () => setLastFeedPost(null)
- }}>
- {children}
- </SignalRContext.Provider>
- )
- }
- export function useSignalRContext() {
- return useContext(SignalRContext);
- }
|