signalrProvider.tsx 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. 'use client';
  2. import { createContext, useContext, useEffect, useState, useRef } from 'react';
  3. import * as signalR from '@microsoft/signalr';
  4. import { fetchApi } from '@/lib/utils/client';
  5. type FeedPostEvent = {
  6. postID: number;
  7. authorSID: string;
  8. authorName: string|null;
  9. authorThumb: string|null;
  10. subject: string;
  11. thumbnail: string|null;
  12. createdAt: string;
  13. };
  14. // Backend hub 매핑
  15. // chatConnection → /hubs/chat (ChatHub) — 채팅, 향후 1:1 실시간 문의
  16. // appConnection → /hubs/app (AppHub) — 알림, 쪽지, 피드 토스트
  17. const SignalRContext = createContext<{
  18. chatConnection: signalR.HubConnection|null;
  19. chatConnected: boolean;
  20. appConnection: signalR.HubConnection|null;
  21. appConnected: boolean;
  22. stopConnections: () => Promise<void>;
  23. reconnectChat: (accessToken?: string|null) => Promise<void>;
  24. reconnectApp: (accessToken?: string|null) => Promise<void>;
  25. lastFeedPost: FeedPostEvent|null;
  26. clearLastFeedPost: () => void;
  27. }>({
  28. chatConnection: null,
  29. chatConnected: false,
  30. appConnection: null,
  31. appConnected: false,
  32. stopConnections: async () => {},
  33. reconnectChat: async () => {},
  34. reconnectApp: async () => {},
  35. lastFeedPost: null,
  36. clearLastFeedPost: () => {}
  37. });
  38. type Props = {
  39. children: React.ReactNode;
  40. accessToken: string|null;
  41. signalRChatUrl: string;
  42. signalRAppUrl: string;
  43. }
  44. export function SignalRProvider({ children, accessToken, signalRChatUrl, signalRAppUrl }: Props) {
  45. const chatConnectionRef = useRef<signalR.HubConnection|null>(null);
  46. const appConnectionRef = useRef<signalR.HubConnection|null>(null);
  47. const [chatConnection, setChatConnection] = useState<signalR.HubConnection|null>(null);
  48. const [chatConnected, setChatConnected] = useState<boolean>(false);
  49. const [appConnection, setAppConnection] = useState<signalR.HubConnection|null>(null);
  50. const [appConnected, setAppConnected] = useState<boolean>(false);
  51. const [lastFeedPost, setLastFeedPost] = useState<FeedPostEvent|null>(null);
  52. // 초기 1회만 시작 — 토큰 갱신 시에는 reconnectChat / reconnectApp 으로 수동 재연결
  53. useEffect(() => {
  54. initChatConnection(accessToken);
  55. initAppConnection(accessToken);
  56. return () => {
  57. stopConnections();
  58. };
  59. }, []);
  60. useEffect(() => {
  61. if (chatConnected) {
  62. console.info('SignalR Chat Connected');
  63. }
  64. }, [chatConnected]);
  65. useEffect(() => {
  66. if (appConnected) {
  67. console.info('SignalR App Connected');
  68. }
  69. }, [appConnected]);
  70. const buildConn = (url: string, token: string|null|undefined) => {
  71. const options = token ? { accessTokenFactory: async () => token, withCredentials: true } : {};
  72. return new signalR.HubConnectionBuilder().withUrl(url, options).withAutomaticReconnect().build();
  73. };
  74. const initChatConnection = async (token?: string|null) => {
  75. if (!signalRChatUrl) {
  76. console.warn('SIGNALR_CHAT_URL not configured — chat hub disabled');
  77. return;
  78. }
  79. try {
  80. if (chatConnectionRef.current && chatConnectionRef.current.state !== signalR.HubConnectionState.Disconnected) {
  81. await chatConnectionRef.current.stop();
  82. }
  83. const conn = buildConn(signalRChatUrl, token);
  84. await conn.start();
  85. conn.on('Connected', (message) => {
  86. console.info('[chat]', message);
  87. });
  88. conn.on('Logout', (message) => {
  89. console.info('[chat]', message);
  90. });
  91. chatConnectionRef.current = conn;
  92. setChatConnection(conn);
  93. setChatConnected(true);
  94. } catch (error) {
  95. console.error('SignalR Chat Connect Failed:', error);
  96. }
  97. };
  98. const initAppConnection = async (token?: string|null) => {
  99. if (!signalRAppUrl) {
  100. console.warn('SIGNALR_APP_URL not configured — app hub disabled');
  101. return;
  102. }
  103. try {
  104. if (appConnectionRef.current && appConnectionRef.current.state !== signalR.HubConnectionState.Disconnected) {
  105. await appConnectionRef.current.stop();
  106. }
  107. const conn = buildConn(signalRAppUrl, token);
  108. await conn.start();
  109. conn.on('Connected', (message) => {
  110. console.info('[app]', message);
  111. });
  112. conn.on('Logout', (message) => {
  113. console.info('[app]', message);
  114. });
  115. // ── 피드 이벤트 ──
  116. conn.on('ReceiveFeedPost', (data: FeedPostEvent) => {
  117. setLastFeedPost(data);
  118. });
  119. // ── 강제 종료 ──
  120. conn.on('Kick', async () => {
  121. fetchApi('/api/auth/logout', {
  122. method: 'POST'
  123. }).then(() => {
  124. alert('관리자에 의해 강제 종료되었습니다.');
  125. localStorage.setItem('rememberMe', "false");
  126. localStorage.removeItem('member');
  127. location.replace('/');
  128. });
  129. });
  130. appConnectionRef.current = conn;
  131. setAppConnection(conn);
  132. setAppConnected(true);
  133. } catch (error) {
  134. console.error('SignalR App Connect Failed:', error);
  135. }
  136. };
  137. const stopConnections = async () => {
  138. if (chatConnectionRef.current && chatConnectionRef.current.state === signalR.HubConnectionState.Connected) {
  139. try {
  140. await chatConnectionRef.current.invoke('Logout');
  141. setChatConnected(false);
  142. } catch (error) {
  143. console.error('SignalR Chat Disconnect Failed:', error);
  144. }
  145. }
  146. if (appConnectionRef.current && appConnectionRef.current.state === signalR.HubConnectionState.Connected) {
  147. try {
  148. await appConnectionRef.current.invoke('Logout');
  149. setAppConnected(false);
  150. } catch (error) {
  151. console.error('SignalR App Disconnect Failed:', error);
  152. }
  153. }
  154. };
  155. const reconnectChat = async (token?: string|null) => {
  156. await initChatConnection(token);
  157. };
  158. const reconnectApp = async (token?: string|null) => {
  159. await initAppConnection(token);
  160. };
  161. return (
  162. <SignalRContext.Provider value={{
  163. chatConnection,
  164. chatConnected,
  165. appConnection,
  166. appConnected,
  167. stopConnections,
  168. reconnectChat,
  169. reconnectApp,
  170. lastFeedPost,
  171. clearLastFeedPost: () => setLastFeedPost(null)
  172. }}>
  173. {children}
  174. </SignalRContext.Provider>
  175. )
  176. }
  177. export function useSignalRContext() {
  178. return useContext(SignalRContext);
  179. }