import type { Dispatch, SetStateAction } from 'react'; import { useEffect, useState } from 'react'; import { CHAT_CONNECTION, diagnoseConnectionFailure, getLastReceivedChatEventId, handleChatServerEvent, persistLastReceivedChatEventId, resolveChatWebSocketUrl, } from './chatUtils'; import { hasRegisteredAccessTokenAccess } from '../tokenAccess'; import type { ChatActivityEvent, ChatJobEvent, ChatMessage, ChatRuntimeJobDetail, ChatRuntimeSnapshot, ChatViewContext, } from './types'; const DISCONNECT_UI_DELAY_MS = 1500; const PRESENCE_PING_INTERVAL_MS = 20_000; type ConnectionState = 'connecting' | 'connected' | 'disconnected'; type UseChatConnectionOptions = { sessionId: string; currentContext: ChatViewContext; setMessages: Dispatch>; onMessageEvent?: (message: ChatMessage, sessionId: string) => void; onJobEvent?: (event: ChatJobEvent, sessionId: string) => void; onRuntimeEvent?: (snapshot: ChatRuntimeSnapshot) => void; onRuntimeDetailEvent?: (detail: ChatRuntimeJobDetail) => void; onActivityEvent?: (event: ChatActivityEvent) => void; }; type SharedChatConnectionState = { connectionState: ConnectionState; connectionErrorDetail: string; runtimeSnapshot: ChatRuntimeSnapshot | null; }; type SharedChatConnection = SharedChatConnectionState & { socketRef: { current: WebSocket | null }; reconnectTimerId: number | null; disconnectUiTimerId: number | null; connectTimeoutId: number | null; sessionId: string; currentContext: ChatViewContext | null; setMessages: Dispatch> | null; onMessageEvent?: ((message: ChatMessage, sessionId: string) => void) | undefined; onJobEvent?: ((event: ChatJobEvent, sessionId: string) => void) | undefined; onRuntimeEvent?: ((snapshot: ChatRuntimeSnapshot) => void) | undefined; onRuntimeDetailEvent?: ((detail: ChatRuntimeJobDetail) => void) | undefined; onActivityEvent?: ((event: ChatActivityEvent) => void) | undefined; lastEventId: number; websocketUrl: string; subscribers: Set<() => void>; pingSubscriberCount: number; consumerCount: number; pingIntervalId: number | null; visibilityHandlerInstalled: boolean; pageShowHandlerInstalled: boolean; focusHandlerInstalled: boolean; onlineHandlerInstalled: boolean; hasConnectedOnce: boolean; suppressDisconnectNotification: boolean; lastBackgroundAt: number | null; }; const sharedChatConnection: SharedChatConnection = { connectionState: 'connecting', connectionErrorDetail: '', runtimeSnapshot: null, socketRef: { current: null }, reconnectTimerId: null, disconnectUiTimerId: null, connectTimeoutId: null, sessionId: '', currentContext: null, setMessages: null, onMessageEvent: undefined, onJobEvent: undefined, onRuntimeEvent: undefined, onRuntimeDetailEvent: undefined, onActivityEvent: undefined, lastEventId: 0, websocketUrl: '', subscribers: new Set(), pingSubscriberCount: 0, consumerCount: 0, pingIntervalId: null, visibilityHandlerInstalled: false, pageShowHandlerInstalled: false, focusHandlerInstalled: false, onlineHandlerInstalled: false, hasConnectedOnce: false, suppressDisconnectNotification: false, lastBackgroundAt: null, }; function emitSharedState() { sharedChatConnection.subscribers.forEach((listener) => { listener(); }); } function getSnapshot(): SharedChatConnectionState { return { connectionState: sharedChatConnection.connectionState, connectionErrorDetail: sharedChatConnection.connectionErrorDetail, runtimeSnapshot: sharedChatConnection.runtimeSnapshot, }; } export function getChatConnectionSnapshot() { return getSnapshot(); } export function subscribeChatConnection(listener: () => void) { sharedChatConnection.subscribers.add(listener); return () => { sharedChatConnection.subscribers.delete(listener); }; } export function getSharedChatRuntimeSnapshot() { return sharedChatConnection.runtimeSnapshot; } export function setSharedChatRuntimeSnapshot(snapshot: ChatRuntimeSnapshot | null) { if (sharedChatConnection.runtimeSnapshot === snapshot) { return; } sharedChatConnection.runtimeSnapshot = snapshot; emitSharedState(); } function setSharedConnectionState(nextState: ConnectionState) { if (sharedChatConnection.connectionState === nextState) { return; } sharedChatConnection.connectionState = nextState; emitSharedState(); } function setSharedConnectionError(detail: string) { if (sharedChatConnection.connectionErrorDetail === detail) { return; } sharedChatConnection.connectionErrorDetail = detail; emitSharedState(); } function clearReconnectTimer() { if (sharedChatConnection.reconnectTimerId !== null) { window.clearTimeout(sharedChatConnection.reconnectTimerId); sharedChatConnection.reconnectTimerId = null; } } function clearDisconnectUiTimer() { if (sharedChatConnection.disconnectUiTimerId !== null) { window.clearTimeout(sharedChatConnection.disconnectUiTimerId); sharedChatConnection.disconnectUiTimerId = null; } } function clearConnectTimeout() { if (sharedChatConnection.connectTimeoutId !== null) { window.clearTimeout(sharedChatConnection.connectTimeoutId); sharedChatConnection.connectTimeoutId = null; } } function sendContextUpdate(context: ChatViewContext | null = sharedChatConnection.currentContext) { const socket = sharedChatConnection.socketRef.current; if (!socket || socket.readyState !== WebSocket.OPEN || !context) { return; } const liveVisibilityState = typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible'; const livePageUrl = typeof window !== 'undefined' ? window.location.href : context.pageUrl; socket.send( JSON.stringify({ type: 'context:update', payload: { pageId: context.pageId, pageTitle: context.pageTitle, topMenu: context.topMenu, focusedComponentId: context.focusedComponentId, pageUrl: livePageUrl, isStandaloneMode: context.isStandaloneMode, pageVisibilityState: liveVisibilityState, chatTypeId: context.chatTypeId, chatTypeLabel: context.chatTypeLabel, chatTypeDescription: context.chatTypeDescription, chatTypeIsTemplate: context.chatTypeIsTemplate, }, }), ); } function sendPresencePing() { const socket = sharedChatConnection.socketRef.current; if (!socket || socket.readyState !== WebSocket.OPEN) { return; } socket.send( JSON.stringify({ type: 'presence:ping', payload: { at: Date.now(), }, }), ); } function ensureSharedSocket() { const socket = sharedChatConnection.socketRef.current; if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) { return; } connectSharedSocket(); } function sendEventReceived(eventId: number) { const socket = sharedChatConnection.socketRef.current; if (!socket || socket.readyState !== WebSocket.OPEN || !Number.isFinite(eventId) || eventId <= 0) { return; } socket.send( JSON.stringify({ type: 'event:received', payload: { eventId, }, }), ); } function stopPresenceMonitoring() { if (sharedChatConnection.pingIntervalId !== null) { window.clearInterval(sharedChatConnection.pingIntervalId); sharedChatConnection.pingIntervalId = null; } if (sharedChatConnection.visibilityHandlerInstalled) { window.removeEventListener('visibilitychange', handleVisibilityChange); sharedChatConnection.visibilityHandlerInstalled = false; } if (sharedChatConnection.pageShowHandlerInstalled) { window.removeEventListener('pageshow', handlePageShow); sharedChatConnection.pageShowHandlerInstalled = false; } if (sharedChatConnection.focusHandlerInstalled) { window.removeEventListener('focus', handleWindowFocus); sharedChatConnection.focusHandlerInstalled = false; } if (sharedChatConnection.onlineHandlerInstalled) { window.removeEventListener('online', handleWindowOnline); sharedChatConnection.onlineHandlerInstalled = false; } } function handleVisibilityChange() { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { sharedChatConnection.lastBackgroundAt = Date.now(); sendContextUpdate(sharedChatConnection.currentContext); sendPresencePing(); return; } ensureSharedSocket(); sendPresencePing(); sendContextUpdate(sharedChatConnection.currentContext); } function handlePageShow() { ensureSharedSocket(); sendPresencePing(); sendContextUpdate(sharedChatConnection.currentContext); } function handleWindowFocus() { ensureSharedSocket(); sendPresencePing(); } function handleWindowOnline() { ensureSharedSocket(); } function startPresenceMonitoring() { if (sharedChatConnection.pingSubscriberCount <= 0 || sharedChatConnection.connectionState !== 'connected') { stopPresenceMonitoring(); return; } sharedChatConnection.lastBackgroundAt = null; sendPresencePing(); if (sharedChatConnection.pingIntervalId === null) { sharedChatConnection.pingIntervalId = window.setInterval(() => { sendPresencePing(); }, PRESENCE_PING_INTERVAL_MS); } if (!sharedChatConnection.visibilityHandlerInstalled) { window.addEventListener('visibilitychange', handleVisibilityChange); sharedChatConnection.visibilityHandlerInstalled = true; } if (!sharedChatConnection.pageShowHandlerInstalled) { window.addEventListener('pageshow', handlePageShow); sharedChatConnection.pageShowHandlerInstalled = true; } if (!sharedChatConnection.focusHandlerInstalled) { window.addEventListener('focus', handleWindowFocus); sharedChatConnection.focusHandlerInstalled = true; } if (!sharedChatConnection.onlineHandlerInstalled) { window.addEventListener('online', handleWindowOnline); sharedChatConnection.onlineHandlerInstalled = true; } } function scheduleReconnect() { if (sharedChatConnection.reconnectTimerId !== null || !sharedChatConnection.sessionId) { return; } sharedChatConnection.reconnectTimerId = window.setTimeout(() => { sharedChatConnection.reconnectTimerId = null; connectSharedSocket(); }, CHAT_CONNECTION.reconnectDelayMs); } function handleSharedDisconnect(message?: string, detail?: string) { setSharedConnectionError(detail ?? ''); clearDisconnectUiTimer(); if (sharedChatConnection.connectionState !== 'connected') { setSharedConnectionState('disconnected'); } else { sharedChatConnection.disconnectUiTimerId = window.setTimeout(() => { sharedChatConnection.disconnectUiTimerId = null; if (sharedChatConnection.socketRef.current?.readyState === WebSocket.OPEN) { return; } setSharedConnectionState('disconnected'); }, DISCONNECT_UI_DELAY_MS); } if (message) { scheduleReconnect(); } } function disconnectSharedSocket() { clearReconnectTimer(); clearConnectTimeout(); clearDisconnectUiTimer(); stopPresenceMonitoring(); const socket = sharedChatConnection.socketRef.current; sharedChatConnection.suppressDisconnectNotification = true; sharedChatConnection.socketRef.current = null; socket?.close(); } function releaseSharedConnectionConsumer() { sharedChatConnection.consumerCount = Math.max(0, sharedChatConnection.consumerCount - 1); if (sharedChatConnection.consumerCount > 0) { return; } sharedChatConnection.currentContext = null; sharedChatConnection.setMessages = null; sharedChatConnection.onMessageEvent = undefined; sharedChatConnection.onJobEvent = undefined; sharedChatConnection.onRuntimeEvent = undefined; sharedChatConnection.onRuntimeDetailEvent = undefined; setSharedChatRuntimeSnapshot(null); disconnectSharedSocket(); setSharedConnectionError(''); setSharedConnectionState('disconnected'); } function connectSharedSocket() { if (!sharedChatConnection.sessionId || !sharedChatConnection.setMessages) { return; } if (!hasRegisteredAccessTokenAccess()) { clearReconnectTimer(); clearConnectTimeout(); clearDisconnectUiTimer(); stopPresenceMonitoring(); setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결을 시작하지 않았습니다.'); setSharedConnectionState('disconnected'); return; } const currentSocket = sharedChatConnection.socketRef.current; if (currentSocket && (currentSocket.readyState === WebSocket.OPEN || currentSocket.readyState === WebSocket.CONNECTING)) { return; } clearReconnectTimer(); clearConnectTimeout(); clearDisconnectUiTimer(); if (sharedChatConnection.connectionState !== 'connected') { setSharedConnectionState('connecting'); } sharedChatConnection.websocketUrl = resolveChatWebSocketUrl(sharedChatConnection.sessionId, sharedChatConnection.lastEventId); let socket: WebSocket; try { socket = new WebSocket(sharedChatConnection.websocketUrl); } catch { handleSharedDisconnect( `워크서버 WebSocket 주소가 올바르지 않습니다. 대상: ${sharedChatConnection.websocketUrl || '/ws/chat'} 자동으로 다시 연결합니다.`, 'WebSocket 객체를 생성하지 못했습니다. 대상 주소 형식과 환경변수를 확인해 주세요.', ); return; } sharedChatConnection.socketRef.current = socket; sharedChatConnection.suppressDisconnectNotification = false; let disconnectHandled = false; const reportDisconnect = (message?: string, closeEvent?: CloseEvent) => { if (disconnectHandled) { return; } disconnectHandled = true; const wasSuppressed = sharedChatConnection.suppressDisconnectNotification; if (sharedChatConnection.socketRef.current === socket) { sharedChatConnection.socketRef.current = null; } sharedChatConnection.suppressDisconnectNotification = false; if (wasSuppressed) { setSharedConnectionError(''); return; } if (closeEvent?.code === 1008) { clearReconnectTimer(); setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결이 차단되었습니다.'); setSharedConnectionState('disconnected'); return; } if (closeEvent?.code === 1000 && !message) { setSharedConnectionError(''); return; } void diagnoseConnectionFailure(sharedChatConnection.websocketUrl, closeEvent).then((detail) => { handleSharedDisconnect(message, detail); }); }; sharedChatConnection.connectTimeoutId = window.setTimeout(() => { if (sharedChatConnection.socketRef.current !== socket || socket.readyState === WebSocket.OPEN) { return; } sharedChatConnection.socketRef.current = null; socket.close(); reportDisconnect( `워크서버 연결 시간이 초과되었습니다. 대상: ${sharedChatConnection.websocketUrl || '/ws/chat'} 자동으로 다시 연결합니다.`, ); }, CHAT_CONNECTION.connectTimeoutMs); socket.addEventListener('open', () => { clearConnectTimeout(); clearDisconnectUiTimer(); sharedChatConnection.hasConnectedOnce = true; sharedChatConnection.suppressDisconnectNotification = false; setSharedConnectionState('connected'); setSharedConnectionError(''); sendContextUpdate(sharedChatConnection.currentContext); startPresenceMonitoring(); }); socket.addEventListener('message', (event) => { const setMessages = sharedChatConnection.setMessages; if (!setMessages) { return; } void handleChatServerEvent({ eventData: String(event.data), currentPageUrl: sharedChatConnection.currentContext?.pageUrl ?? '', expectedSessionId: sharedChatConnection.sessionId, setMessages, onMessageEvent: sharedChatConnection.onMessageEvent, onJobEvent: sharedChatConnection.onJobEvent, onRuntimeEvent: sharedChatConnection.onRuntimeEvent, onRuntimeDetailEvent: sharedChatConnection.onRuntimeDetailEvent, onActivityEvent: sharedChatConnection.onActivityEvent, onEventReceived: (eventId) => { sharedChatConnection.lastEventId = eventId; persistLastReceivedChatEventId(sharedChatConnection.sessionId, eventId); sendEventReceived(eventId); }, }); try { const parsedEvent = JSON.parse(String(event.data)) as ChatRuntimeEventEnvelope | null; if (parsedEvent?.type === 'chat:runtime') { setSharedChatRuntimeSnapshot(parsedEvent.payload); } } catch { // ignore malformed payloads here; detailed parsing is already handled downstream } }); socket.addEventListener('close', (event) => { clearConnectTimeout(); stopPresenceMonitoring(); reportDisconnect( event.code === 1000 ? undefined : '워크서버 연결이 끊어졌습니다. 자동으로 다시 연결합니다.', event, ); }); socket.addEventListener('error', () => { clearConnectTimeout(); stopPresenceMonitoring(); reportDisconnect('워크서버 WebSocket 연결에 실패했습니다. 자동으로 다시 연결합니다.'); }); } type ChatRuntimeEventEnvelope = { type: 'chat:runtime'; payload: ChatRuntimeSnapshot; }; function ensureSharedConnection(options: UseChatConnectionOptions) { const sessionChanged = sharedChatConnection.sessionId !== options.sessionId; sharedChatConnection.currentContext = options.currentContext; sharedChatConnection.setMessages = options.setMessages; sharedChatConnection.onMessageEvent = options.onMessageEvent; sharedChatConnection.onJobEvent = options.onJobEvent; sharedChatConnection.onRuntimeEvent = options.onRuntimeEvent; sharedChatConnection.onRuntimeDetailEvent = options.onRuntimeDetailEvent; sharedChatConnection.onActivityEvent = options.onActivityEvent; if (sessionChanged) { sharedChatConnection.sessionId = options.sessionId; sharedChatConnection.lastEventId = getLastReceivedChatEventId(options.sessionId); sharedChatConnection.hasConnectedOnce = false; disconnectSharedSocket(); } connectSharedSocket(); } export function useChatConnection({ sessionId, currentContext, setMessages, onMessageEvent, onJobEvent, onRuntimeEvent, onRuntimeDetailEvent, onActivityEvent, }: UseChatConnectionOptions) { const [snapshot, setSnapshot] = useState(() => getSnapshot()); useEffect(() => { sharedChatConnection.consumerCount += 1; return () => { releaseSharedConnectionConsumer(); }; }, []); useEffect(() => { const handleSnapshotChange = () => { setSnapshot(getSnapshot()); }; const unsubscribe = subscribeChatConnection(handleSnapshotChange); ensureSharedConnection({ sessionId, currentContext, setMessages, onMessageEvent, onJobEvent, onRuntimeEvent, onRuntimeDetailEvent, onActivityEvent, }); handleSnapshotChange(); return () => { unsubscribe(); }; }, [sessionId, setMessages]); useEffect(() => { sharedChatConnection.currentContext = currentContext; sharedChatConnection.setMessages = setMessages; sharedChatConnection.onMessageEvent = onMessageEvent; sharedChatConnection.onJobEvent = onJobEvent; sharedChatConnection.onRuntimeEvent = onRuntimeEvent; sharedChatConnection.onRuntimeDetailEvent = onRuntimeDetailEvent; sharedChatConnection.onActivityEvent = onActivityEvent; sendContextUpdate(currentContext); }, [ currentContext, onMessageEvent, onJobEvent, onRuntimeEvent, onRuntimeDetailEvent, onActivityEvent, setMessages, ]); useEffect(() => { sharedChatConnection.pingSubscriberCount += 1; startPresenceMonitoring(); return () => { sharedChatConnection.pingSubscriberCount = Math.max(0, sharedChatConnection.pingSubscriberCount - 1); if (sharedChatConnection.pingSubscriberCount === 0) { stopPresenceMonitoring(); } }; }, []); return { connectionState: snapshot.connectionState, connectionErrorDetail: snapshot.connectionErrorDetail, socketRef: sharedChatConnection.socketRef, }; }