forked from cardosofelipe/pragma-stack
- 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.
410 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|