Files
syndarix/frontend/src/lib/hooks/useProjectEvents.ts
Felipe Cardoso c17fdab3d3 refactor(frontend): clean up code by consolidating multi-line JSX into single lines where feasible
- Refactored JSX elements to improve readability by collapsing multi-line props and attributes into single lines if their length permits.
- Improved consistency in component imports by grouping and consolidating them.
- No functional changes, purely restructuring for clarity and maintainability.
2026-01-01 11:46:57 +01:00

410 lines
12 KiB
TypeScript

/**
* 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 <ConnectionStatus state={connectionState} onReconnect={reconnect} />;
* }
*
* return <EventList events={events} />;
* ```
*/
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<ConnectionState>('disconnected');
const [error, setError] = useState<SSEError | null>(null);
const [retryCount, setRetryCount] = useState(0);
// Refs for cleanup and reconnection logic
const eventSourceRef = useRef<EventSource | null>(null);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentRetryDelayRef = useRef(initialRetryDelay);
const isManualDisconnectRef = useRef(false);
const mountedRef = useRef(true);
const pingHandlerRef = 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
*/
const getNextRetryDelay = useCallback(() => {
const nextDelay = currentRetryDelayRef.current * BACKOFF_MULTIPLIER;
currentRetryDelayRef.current = Math.min(nextDelay, maxRetryDelay);
return currentRetryDelayRef.current;
}, [maxRetryDelay]);
/**
* Schedule reconnection attempt
*/
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);
connect();
}, delay);
}, [retryCount, maxRetryAttempts, getNextRetryDelay, updateConnectionState]);
/**
* Connect to SSE endpoint
*/
const connect = useCallback(() => {
// Prevent connection if not authenticated or no project ID
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;
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
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);
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,
]);
/**
* 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]);
// Consolidated connection management effect
// Handles both initial mount and auth state changes to prevent race conditions
useEffect(() => {
mountedRef.current = true;
// 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();
}
return () => {
mountedRef.current = false;
cleanup();
};
}, [
autoConnect,
isAuthenticated,
accessToken,
projectId,
connectionState,
connect,
disconnect,
cleanup,
]);
return {
events,
isConnected: connectionState === 'connected',
connectionState,
error,
retryCount,
reconnect,
disconnect,
clearEvents,
};
}