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:
2025-12-30 01:34:41 +01:00
parent d6db6af964
commit fcda8f0f96
14 changed files with 3138 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
/**
* Custom React Hooks
*
* @module lib/hooks
*/
export { useProjectEvents, type UseProjectEventsOptions, type UseProjectEventsResult } from './useProjectEvents';

View 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,
};
}