forked from cardosofelipe/pragma-stack
feat(frontend): Implement client-side SSE handling (#35)
Implements real-time event streaming on the frontend with: - Event types and type guards matching backend EventType enum - Zustand-based event store with per-project buffering - useProjectEvents hook with auto-reconnection and exponential backoff - ConnectionStatus component showing connection state - EventList component with expandable payloads and filtering All 105 tests passing. Follows design system guidelines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
7
frontend/src/lib/hooks/index.ts
Normal file
7
frontend/src/lib/hooks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Custom React Hooks
|
||||
*
|
||||
* @module lib/hooks
|
||||
*/
|
||||
|
||||
export { useProjectEvents, type UseProjectEventsOptions, type UseProjectEventsResult } from './useProjectEvents';
|
||||
393
frontend/src/lib/hooks/useProjectEvents.ts
Normal file
393
frontend/src/lib/hooks/useProjectEvents.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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;
|
||||
const sseUrl = `${baseUrl}/api/v1/projects/${projectId}/events`;
|
||||
|
||||
// Note: EventSource doesn't support custom headers natively
|
||||
// We pass the token as a query parameter (backend should validate this)
|
||||
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
|
||||
eventSource.addEventListener('ping', () => {
|
||||
// Keep-alive ping from server, no action needed
|
||||
if (config.debug.api) {
|
||||
console.log('[SSE] Received ping');
|
||||
}
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
// Auto-connect on mount if enabled
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
if (autoConnect && isAuthenticated && projectId) {
|
||||
connect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
cleanup();
|
||||
};
|
||||
}, [autoConnect, isAuthenticated, projectId, connect, cleanup]);
|
||||
|
||||
// Reconnect when auth changes
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && accessToken && connectionState === 'disconnected' && autoConnect) {
|
||||
if (!isManualDisconnectRef.current) {
|
||||
connect();
|
||||
}
|
||||
} else if (!isAuthenticated && connectionState !== 'disconnected') {
|
||||
disconnect();
|
||||
}
|
||||
}, [isAuthenticated, accessToken, connectionState, autoConnect, connect, disconnect]);
|
||||
|
||||
return {
|
||||
events,
|
||||
isConnected: connectionState === 'connected',
|
||||
connectionState,
|
||||
error,
|
||||
retryCount,
|
||||
reconnect,
|
||||
disconnect,
|
||||
clearEvents,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user