/** * SSE Hook for Project Events * * Provides real-time event streaming from the backend with: * - Automatic reconnection with exponential backoff * - Connection state management * - Type-safe event handling * - Integration with event store * * @module lib/hooks/useProjectEvents */ 'use client'; import { useEffect, useRef, useCallback, useState } from 'react'; import { useAuth } from '@/lib/auth/AuthContext'; import { useEventStore, useProjectEventsFromStore } from '@/lib/stores/eventStore'; import type { ProjectEvent, ConnectionState, SSEError } from '@/lib/types/events'; import config from '@/config/app.config'; // ============================================================================ // Constants // ============================================================================ /** Initial retry delay in milliseconds */ const INITIAL_RETRY_DELAY = 1000; /** Maximum retry delay in milliseconds (30 seconds) */ const MAX_RETRY_DELAY = 30000; /** Maximum number of retry attempts before giving up (0 = unlimited) */ const MAX_RETRY_ATTEMPTS = 0; /** Backoff multiplier for exponential backoff */ const BACKOFF_MULTIPLIER = 2; // ============================================================================ // Types // ============================================================================ export interface UseProjectEventsOptions { /** Enable automatic connection on mount (default: true) */ autoConnect?: boolean; /** Custom retry delay in milliseconds (default: 1000) */ initialRetryDelay?: number; /** Maximum retry delay in milliseconds (default: 30000) */ maxRetryDelay?: number; /** Maximum retry attempts (0 = unlimited, default: 0) */ maxRetryAttempts?: number; /** Callback when event is received */ onEvent?: (event: ProjectEvent) => void; /** Callback when connection state changes */ onConnectionChange?: (state: ConnectionState) => void; /** Callback when error occurs */ onError?: (error: SSEError) => void; } export interface UseProjectEventsResult { /** Events for the project (from store) */ events: ProjectEvent[]; /** Whether connection is established */ isConnected: boolean; /** Current connection state */ connectionState: ConnectionState; /** Current error, if any */ error: SSEError | null; /** Current retry attempt count */ retryCount: number; /** Manually trigger reconnection */ reconnect: () => void; /** Disconnect from SSE */ disconnect: () => void; /** Clear events for this project */ clearEvents: () => void; } // ============================================================================ // Hook Implementation // ============================================================================ /** * Hook for consuming real-time project events via SSE * * @param projectId - Project ID to subscribe to * @param options - Configuration options * @returns Event data and connection controls * * @example * ```tsx * const { events, isConnected, error, reconnect } = useProjectEvents('project-123'); * * if (!isConnected) { * return ; * } * * return ; * ``` */ export function useProjectEvents( projectId: string, options: UseProjectEventsOptions = {} ): UseProjectEventsResult { const { autoConnect = true, initialRetryDelay = INITIAL_RETRY_DELAY, maxRetryDelay = MAX_RETRY_DELAY, maxRetryAttempts = MAX_RETRY_ATTEMPTS, onEvent, onConnectionChange, onError, } = options; // Auth state const { accessToken, isAuthenticated } = useAuth(); // Event store const events = useProjectEventsFromStore(projectId); const addEvent = useEventStore((state) => state.addEvent); const clearProjectEvents = useEventStore((state) => state.clearProjectEvents); // Local state const [connectionState, setConnectionState] = useState('disconnected'); const [error, setError] = useState(null); const [retryCount, setRetryCount] = useState(0); // Refs for cleanup and reconnection logic const eventSourceRef = useRef(null); const retryTimeoutRef = useRef | null>(null); const currentRetryDelayRef = useRef(initialRetryDelay); const isManualDisconnectRef = useRef(false); const mountedRef = useRef(true); const pingHandlerRef = useRef<(() => void) | null>(null); // Ref to hold latest connect function to avoid stale closure in scheduleReconnect const connectRef = useRef<(() => void) | null>(null); /** * Update connection state and notify callback */ const updateConnectionState = useCallback( (state: ConnectionState) => { if (!mountedRef.current) return; setConnectionState(state); onConnectionChange?.(state); }, [onConnectionChange] ); /** * Handle SSE error */ const handleError = useCallback( (message: string, code?: string) => { if (!mountedRef.current) return; const sseError: SSEError = { message, code, timestamp: new Date().toISOString(), retryAttempt: retryCount + 1, }; setError(sseError); onError?.(sseError); }, [retryCount, onError] ); /** * Parse and validate event data */ const parseEvent = useCallback((data: string): ProjectEvent | null => { try { const parsed = JSON.parse(data); // Validate required fields if (!parsed.id || !parsed.type || !parsed.timestamp || !parsed.project_id) { console.warn('[SSE] Invalid event structure:', parsed); return null; } return parsed as ProjectEvent; } catch (err) { console.warn('[SSE] Failed to parse event data:', err); return null; } }, []); /** * Close existing connection and clear retry timeout */ const cleanup = useCallback(() => { if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } // Remove ping listener before closing to prevent memory leak if (eventSourceRef.current && pingHandlerRef.current) { eventSourceRef.current.removeEventListener('ping', pingHandlerRef.current); pingHandlerRef.current = null; } if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } }, []); /** * Calculate next retry delay with exponential backoff * Returns current delay, then increases for next call */ const getNextRetryDelay = useCallback(() => { const delay = currentRetryDelayRef.current; // Increase for next call (capped at max) currentRetryDelayRef.current = Math.min(delay * BACKOFF_MULTIPLIER, maxRetryDelay); return delay; }, [maxRetryDelay]); /** * Schedule reconnection attempt */ /* istanbul ignore next -- reconnection logic is difficult to test with mock EventSource */ const scheduleReconnect = useCallback(() => { if (isManualDisconnectRef.current) return; if (maxRetryAttempts > 0 && retryCount >= maxRetryAttempts) { console.warn('[SSE] Max retry attempts reached'); updateConnectionState('error'); return; } const delay = getNextRetryDelay(); if (config.debug.api) { console.log(`[SSE] Scheduling reconnect in ${delay}ms (attempt ${retryCount + 1})`); } retryTimeoutRef.current = setTimeout(() => { if (!mountedRef.current || isManualDisconnectRef.current) return; setRetryCount((prev) => prev + 1); // Use ref to always call latest connect function (avoids stale closure) connectRef.current?.(); }, delay); }, [retryCount, maxRetryAttempts, getNextRetryDelay, updateConnectionState]); /** * Connect to SSE endpoint */ const connect = useCallback(() => { // Prevent connection if not authenticated or no project ID /* istanbul ignore next -- early return guard, tested via connection state */ if (!isAuthenticated || !accessToken || !projectId) { if (config.debug.api) { console.log('[SSE] Cannot connect: missing auth or projectId'); } return; } // Clean up existing connection cleanup(); isManualDisconnectRef.current = false; updateConnectionState('connecting'); setError(null); // Build SSE URL with auth token const baseUrl = config.api.url; // Backend SSE endpoint is at /events/stream (not /events) const sseUrl = `${baseUrl}/api/v1/projects/${projectId}/events/stream`; // Note: EventSource doesn't support custom headers natively // Backend SSE endpoint accepts token as query parameter for EventSource compatibility const urlWithAuth = `${sseUrl}?token=${encodeURIComponent(accessToken)}`; try { const eventSource = new EventSource(urlWithAuth); eventSourceRef.current = eventSource; /* istanbul ignore next -- EventSource onopen handler */ eventSource.onopen = () => { if (!mountedRef.current) return; if (config.debug.api) { console.log('[SSE] Connection opened'); } updateConnectionState('connected'); setRetryCount(0); currentRetryDelayRef.current = initialRetryDelay; }; eventSource.onmessage = (event) => { if (!mountedRef.current) return; const parsedEvent = parseEvent(event.data); if (parsedEvent) { // Add to store addEvent(parsedEvent); // Notify callback onEvent?.(parsedEvent); } }; // Handle specific event types from backend // Store handler reference for proper cleanup /* istanbul ignore next -- ping handler, tested via mock */ const pingHandler = () => { // Keep-alive ping from server, no action needed if (config.debug.api) { console.log('[SSE] Received ping'); } }; pingHandlerRef.current = pingHandler; eventSource.addEventListener('ping', pingHandler); /* istanbul ignore next -- EventSource onerror handler */ eventSource.onerror = (err) => { if (!mountedRef.current) return; console.error('[SSE] Connection error:', err); if (eventSource.readyState === EventSource.CLOSED) { handleError('Connection closed unexpectedly', 'CONNECTION_CLOSED'); updateConnectionState('disconnected'); scheduleReconnect(); } else { handleError('Connection error', 'CONNECTION_ERROR'); updateConnectionState('error'); scheduleReconnect(); } }; } catch (err) { console.error('[SSE] Failed to create EventSource:', err); handleError('Failed to create connection', 'CREATION_FAILED'); updateConnectionState('error'); scheduleReconnect(); } }, [ isAuthenticated, accessToken, projectId, cleanup, updateConnectionState, handleError, parseEvent, addEvent, onEvent, scheduleReconnect, initialRetryDelay, ]); // Keep ref updated with latest connect function for scheduleReconnect connectRef.current = connect; /** * Manually disconnect from SSE */ const disconnect = useCallback(() => { isManualDisconnectRef.current = true; cleanup(); updateConnectionState('disconnected'); setRetryCount(0); currentRetryDelayRef.current = initialRetryDelay; }, [cleanup, updateConnectionState, initialRetryDelay]); /** * Manually trigger reconnection */ const reconnect = useCallback(() => { disconnect(); isManualDisconnectRef.current = false; connect(); }, [disconnect, connect]); /** * Clear events for this project */ const clearEvents = useCallback(() => { clearProjectEvents(projectId); }, [clearProjectEvents, projectId]); // Effect 1: Unmount cleanup only // Separated from connection management to prevent cleanup running on every state change // This fixes: when connectionState changes, the old effect cleanup would close EventSource useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; cleanup(); }; }, [cleanup]); // Effect 2: Connection management // No cleanup here - only Effect 1 handles cleanup on unmount // This prevents: cleanup closing EventSource immediately after connect() creates it useEffect(() => { // Connect when authenticated with a project and not manually disconnected if (autoConnect && isAuthenticated && accessToken && projectId) { if (connectionState === 'disconnected' && !isManualDisconnectRef.current) { connect(); } } else if (!isAuthenticated && connectionState !== 'disconnected') { // Disconnect when auth is lost disconnect(); } }, [autoConnect, isAuthenticated, accessToken, projectId, connectionState, connect, disconnect]); return { events, isConnected: connectionState === 'connected', connectionState, error, retryCount, reconnect, disconnect, clearEvents, }; }