signalrProvider.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  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. // ⚡ 최초 페인트/하이드레이션 임계경로에서 빼기 위해 idle 로 지연(핸드셰이크 2건). 알림·쪽지·Kick 은 그대로 동작.
  54. useEffect(() => {
  55. let idleId: number|undefined;
  56. let timeoutId: ReturnType<typeof setTimeout>|undefined;
  57. const start = () => {
  58. initChatConnection(accessToken);
  59. initAppConnection(accessToken);
  60. };
  61. const w = window as typeof window & {
  62. requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
  63. cancelIdleCallback?: (id: number) => void;
  64. };
  65. if (typeof w.requestIdleCallback === 'function') {
  66. idleId = w.requestIdleCallback(start, { timeout: 2000 });
  67. } else {
  68. timeoutId = setTimeout(start, 800);
  69. }
  70. return () => {
  71. if (idleId !== undefined && typeof w.cancelIdleCallback === 'function') {
  72. w.cancelIdleCallback(idleId);
  73. }
  74. if (timeoutId !== undefined) {
  75. clearTimeout(timeoutId);
  76. }
  77. stopConnections();
  78. };
  79. }, []);
  80. useEffect(() => {
  81. if (chatConnected) {
  82. console.info('SignalR Chat Connected');
  83. }
  84. }, [chatConnected]);
  85. useEffect(() => {
  86. if (appConnected) {
  87. console.info('SignalR App Connected');
  88. }
  89. }, [appConnected]);
  90. const buildConn = (url: string, token: string|null|undefined) => {
  91. const options = token ? { accessTokenFactory: async () => token, withCredentials: true } : {};
  92. return new signalR.HubConnectionBuilder().withUrl(url, options).withAutomaticReconnect().build();
  93. };
  94. const initChatConnection = async (token?: string|null) => {
  95. if (!signalRChatUrl) {
  96. console.warn('SIGNALR_CHAT_URL not configured — chat hub disabled');
  97. return;
  98. }
  99. try {
  100. if (chatConnectionRef.current && chatConnectionRef.current.state !== signalR.HubConnectionState.Disconnected) {
  101. await chatConnectionRef.current.stop();
  102. }
  103. const conn = buildConn(signalRChatUrl, token);
  104. await conn.start();
  105. conn.on('Connected', (message) => {
  106. console.info('[chat]', message);
  107. });
  108. conn.on('Logout', (message) => {
  109. console.info('[chat]', message);
  110. });
  111. chatConnectionRef.current = conn;
  112. setChatConnection(conn);
  113. setChatConnected(true);
  114. } catch (error) {
  115. console.error('SignalR Chat Connect Failed:', error);
  116. }
  117. };
  118. const initAppConnection = async (token?: string|null) => {
  119. if (!signalRAppUrl) {
  120. console.warn('SIGNALR_APP_URL not configured — app hub disabled');
  121. return;
  122. }
  123. try {
  124. if (appConnectionRef.current && appConnectionRef.current.state !== signalR.HubConnectionState.Disconnected) {
  125. await appConnectionRef.current.stop();
  126. }
  127. const conn = buildConn(signalRAppUrl, token);
  128. await conn.start();
  129. conn.on('Connected', (message) => {
  130. console.info('[app]', message);
  131. });
  132. conn.on('Logout', (message) => {
  133. console.info('[app]', message);
  134. });
  135. // ── 피드 이벤트 ──
  136. conn.on('ReceiveFeedPost', (data: FeedPostEvent) => {
  137. setLastFeedPost(data);
  138. });
  139. // ── 강제 종료 ──
  140. conn.on('Kick', async () => {
  141. fetchApi('/api/auth/logout', {
  142. method: 'POST'
  143. }).then(() => {
  144. alert('관리자에 의해 강제 종료되었습니다.');
  145. localStorage.setItem('rememberMe', "false");
  146. localStorage.removeItem('member');
  147. location.replace('/');
  148. });
  149. });
  150. appConnectionRef.current = conn;
  151. setAppConnection(conn);
  152. setAppConnected(true);
  153. } catch (error) {
  154. console.error('SignalR App Connect Failed:', error);
  155. }
  156. };
  157. const stopConnections = async () => {
  158. if (chatConnectionRef.current && chatConnectionRef.current.state === signalR.HubConnectionState.Connected) {
  159. try {
  160. await chatConnectionRef.current.invoke('Logout');
  161. setChatConnected(false);
  162. } catch (error) {
  163. console.error('SignalR Chat Disconnect Failed:', error);
  164. }
  165. }
  166. if (appConnectionRef.current && appConnectionRef.current.state === signalR.HubConnectionState.Connected) {
  167. try {
  168. await appConnectionRef.current.invoke('Logout');
  169. setAppConnected(false);
  170. } catch (error) {
  171. console.error('SignalR App Disconnect Failed:', error);
  172. }
  173. }
  174. };
  175. const reconnectChat = async (token?: string|null) => {
  176. await initChatConnection(token);
  177. };
  178. const reconnectApp = async (token?: string|null) => {
  179. await initAppConnection(token);
  180. };
  181. return (
  182. <SignalRContext.Provider value={{
  183. chatConnection,
  184. chatConnected,
  185. appConnection,
  186. appConnected,
  187. stopConnections,
  188. reconnectChat,
  189. reconnectApp,
  190. lastFeedPost,
  191. clearLastFeedPost: () => setLastFeedPost(null)
  192. }}>
  193. {children}
  194. </SignalRContext.Provider>
  195. )
  196. }
  197. export function useSignalRContext() {
  198. return useContext(SignalRContext);
  199. }