Implement Agent 6: Real-Time Updates feature for LocalGreenChain: - Add Socket.io server with room-based subscriptions - Create client-side hooks (useSocket, useLiveFeed, usePlantUpdates) - Add SocketProvider context for application-wide state - Implement UI components: - ConnectionStatus: Shows WebSocket connection state - LiveFeed: Real-time event feed display - NotificationToast: Toast notifications with auto-dismiss - LiveChart: Real-time data visualization - Add event type definitions and formatting utilities - Create socket API endpoint for WebSocket initialization - Add socket stats endpoint for monitoring - Extend tailwind with fadeIn/slideIn animations Integrates with existing EventStream SSE system for fallback.
379 lines
8.8 KiB
TypeScript
379 lines
8.8 KiB
TypeScript
/**
|
|
* Socket.io Client for LocalGreenChain
|
|
*
|
|
* Provides a client-side wrapper for Socket.io connections.
|
|
*/
|
|
|
|
import { io, Socket } from 'socket.io-client';
|
|
import type {
|
|
ClientToServerEvents,
|
|
ServerToClientEvents,
|
|
ConnectionStatus,
|
|
RoomType,
|
|
TransparencyEventType,
|
|
TransparencyEvent,
|
|
ConnectionMetrics,
|
|
} from './types';
|
|
|
|
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
|
|
|
/**
|
|
* Client configuration options
|
|
*/
|
|
export interface SocketClientConfig {
|
|
url?: string;
|
|
path?: string;
|
|
autoConnect?: boolean;
|
|
auth?: {
|
|
userId?: string;
|
|
token?: string;
|
|
sessionId?: string;
|
|
};
|
|
reconnection?: boolean;
|
|
reconnectionAttempts?: number;
|
|
reconnectionDelay?: number;
|
|
}
|
|
|
|
/**
|
|
* Event listener types
|
|
*/
|
|
export type EventListener = (event: TransparencyEvent) => void;
|
|
export type StatusListener = (status: ConnectionStatus) => void;
|
|
export type ErrorListener = (error: { code: string; message: string }) => void;
|
|
|
|
/**
|
|
* Socket.io client wrapper
|
|
*/
|
|
class RealtimeSocketClient {
|
|
private socket: TypedSocket | null = null;
|
|
private config: SocketClientConfig;
|
|
private status: ConnectionStatus = 'disconnected';
|
|
private eventListeners: Set<EventListener> = new Set();
|
|
private statusListeners: Set<StatusListener> = new Set();
|
|
private errorListeners: Set<ErrorListener> = new Set();
|
|
private metrics: ConnectionMetrics;
|
|
private pingInterval: NodeJS.Timeout | null = null;
|
|
|
|
constructor(config: SocketClientConfig = {}) {
|
|
this.config = {
|
|
url: typeof window !== 'undefined' ? window.location.origin : '',
|
|
path: '/api/socket',
|
|
autoConnect: true,
|
|
reconnection: true,
|
|
reconnectionAttempts: 10,
|
|
reconnectionDelay: 1000,
|
|
...config,
|
|
};
|
|
|
|
this.metrics = {
|
|
status: 'disconnected',
|
|
eventsReceived: 0,
|
|
reconnectAttempts: 0,
|
|
rooms: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Connect to the server
|
|
*/
|
|
connect(): void {
|
|
if (this.socket?.connected) {
|
|
return;
|
|
}
|
|
|
|
this.updateStatus('connecting');
|
|
|
|
this.socket = io(this.config.url!, {
|
|
path: this.config.path,
|
|
autoConnect: this.config.autoConnect,
|
|
auth: this.config.auth,
|
|
reconnection: this.config.reconnection,
|
|
reconnectionAttempts: this.config.reconnectionAttempts,
|
|
reconnectionDelay: this.config.reconnectionDelay,
|
|
transports: ['websocket', 'polling'],
|
|
});
|
|
|
|
this.setupEventHandlers();
|
|
}
|
|
|
|
/**
|
|
* Set up socket event handlers
|
|
*/
|
|
private setupEventHandlers(): void {
|
|
if (!this.socket) return;
|
|
|
|
// Connection events
|
|
this.socket.on('connect', () => {
|
|
this.updateStatus('connected');
|
|
this.metrics.connectedAt = Date.now();
|
|
this.metrics.reconnectAttempts = 0;
|
|
this.startPingInterval();
|
|
});
|
|
|
|
this.socket.on('disconnect', () => {
|
|
this.updateStatus('disconnected');
|
|
this.stopPingInterval();
|
|
});
|
|
|
|
this.socket.on('connect_error', () => {
|
|
this.updateStatus('error');
|
|
this.metrics.reconnectAttempts++;
|
|
});
|
|
|
|
// Server events
|
|
this.socket.on('connection:established', (data) => {
|
|
console.log('[SocketClient] Connected:', data.socketId);
|
|
});
|
|
|
|
this.socket.on('connection:error', (error) => {
|
|
this.errorListeners.forEach((listener) => listener(error));
|
|
});
|
|
|
|
// Real-time events
|
|
this.socket.on('event', (event) => {
|
|
this.metrics.eventsReceived++;
|
|
this.metrics.lastEventAt = Date.now();
|
|
this.eventListeners.forEach((listener) => listener(event));
|
|
});
|
|
|
|
this.socket.on('event:batch', (events) => {
|
|
this.metrics.eventsReceived += events.length;
|
|
this.metrics.lastEventAt = Date.now();
|
|
events.forEach((event) => {
|
|
this.eventListeners.forEach((listener) => listener(event));
|
|
});
|
|
});
|
|
|
|
// Room events
|
|
this.socket.on('room:joined', (room) => {
|
|
if (!this.metrics.rooms.includes(room)) {
|
|
this.metrics.rooms.push(room);
|
|
}
|
|
});
|
|
|
|
this.socket.on('room:left', (room) => {
|
|
this.metrics.rooms = this.metrics.rooms.filter((r) => r !== room);
|
|
});
|
|
|
|
// System events
|
|
this.socket.on('system:message', (message) => {
|
|
console.log(`[SocketClient] System ${message.type}: ${message.text}`);
|
|
});
|
|
|
|
this.socket.on('system:heartbeat', () => {
|
|
// Heartbeat received - connection is alive
|
|
});
|
|
|
|
// Reconnection events
|
|
this.socket.io.on('reconnect_attempt', () => {
|
|
this.updateStatus('reconnecting');
|
|
this.metrics.reconnectAttempts++;
|
|
});
|
|
|
|
this.socket.io.on('reconnect', () => {
|
|
this.updateStatus('connected');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Start ping interval for latency measurement
|
|
*/
|
|
private startPingInterval(): void {
|
|
this.stopPingInterval();
|
|
|
|
this.pingInterval = setInterval(() => {
|
|
if (this.socket?.connected) {
|
|
const start = Date.now();
|
|
this.socket.emit('ping', (serverTime) => {
|
|
this.metrics.latency = Date.now() - start;
|
|
});
|
|
}
|
|
}, 10000); // Every 10 seconds
|
|
}
|
|
|
|
/**
|
|
* Stop ping interval
|
|
*/
|
|
private stopPingInterval(): void {
|
|
if (this.pingInterval) {
|
|
clearInterval(this.pingInterval);
|
|
this.pingInterval = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update connection status and notify listeners
|
|
*/
|
|
private updateStatus(status: ConnectionStatus): void {
|
|
this.status = status;
|
|
this.metrics.status = status;
|
|
this.statusListeners.forEach((listener) => listener(status));
|
|
}
|
|
|
|
/**
|
|
* Disconnect from the server
|
|
*/
|
|
disconnect(): void {
|
|
this.stopPingInterval();
|
|
if (this.socket) {
|
|
this.socket.disconnect();
|
|
this.socket = null;
|
|
}
|
|
this.updateStatus('disconnected');
|
|
}
|
|
|
|
/**
|
|
* Join a room
|
|
*/
|
|
joinRoom(room: RoomType): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
if (!this.socket?.connected) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
this.socket.emit('room:join', room, (success) => {
|
|
resolve(success);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Leave a room
|
|
*/
|
|
leaveRoom(room: RoomType): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
if (!this.socket?.connected) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
this.socket.emit('room:leave', room, (success) => {
|
|
resolve(success);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Subscribe to specific event types
|
|
*/
|
|
subscribeToTypes(types: TransparencyEventType[]): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
if (!this.socket?.connected) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
this.socket.emit('subscribe:types', types, (success) => {
|
|
resolve(success);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe from specific event types
|
|
*/
|
|
unsubscribeFromTypes(types: TransparencyEventType[]): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
if (!this.socket?.connected) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
this.socket.emit('unsubscribe:types', types, (success) => {
|
|
resolve(success);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get recent events
|
|
*/
|
|
getRecentEvents(limit: number = 50): Promise<TransparencyEvent[]> {
|
|
return new Promise((resolve) => {
|
|
if (!this.socket?.connected) {
|
|
resolve([]);
|
|
return;
|
|
}
|
|
|
|
this.socket.emit('events:recent', limit, (events) => {
|
|
resolve(events);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add an event listener
|
|
*/
|
|
onEvent(listener: EventListener): () => void {
|
|
this.eventListeners.add(listener);
|
|
return () => this.eventListeners.delete(listener);
|
|
}
|
|
|
|
/**
|
|
* Add a status listener
|
|
*/
|
|
onStatusChange(listener: StatusListener): () => void {
|
|
this.statusListeners.add(listener);
|
|
return () => this.statusListeners.delete(listener);
|
|
}
|
|
|
|
/**
|
|
* Add an error listener
|
|
*/
|
|
onError(listener: ErrorListener): () => void {
|
|
this.errorListeners.add(listener);
|
|
return () => this.errorListeners.delete(listener);
|
|
}
|
|
|
|
/**
|
|
* Get current connection status
|
|
*/
|
|
getStatus(): ConnectionStatus {
|
|
return this.status;
|
|
}
|
|
|
|
/**
|
|
* Get connection metrics
|
|
*/
|
|
getMetrics(): ConnectionMetrics {
|
|
return { ...this.metrics };
|
|
}
|
|
|
|
/**
|
|
* Check if connected
|
|
*/
|
|
isConnected(): boolean {
|
|
return this.socket?.connected ?? false;
|
|
}
|
|
|
|
/**
|
|
* Get socket ID
|
|
*/
|
|
getSocketId(): string | undefined {
|
|
return this.socket?.id;
|
|
}
|
|
}
|
|
|
|
// Singleton instance for client-side use
|
|
let clientInstance: RealtimeSocketClient | null = null;
|
|
|
|
/**
|
|
* Get the singleton socket client instance
|
|
*/
|
|
export function getSocketClient(config?: SocketClientConfig): RealtimeSocketClient {
|
|
if (!clientInstance) {
|
|
clientInstance = new RealtimeSocketClient(config);
|
|
}
|
|
return clientInstance;
|
|
}
|
|
|
|
/**
|
|
* Create a new socket client instance
|
|
*/
|
|
export function createSocketClient(config?: SocketClientConfig): RealtimeSocketClient {
|
|
return new RealtimeSocketClient(config);
|
|
}
|
|
|
|
export { RealtimeSocketClient };
|
|
export default RealtimeSocketClient;
|