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.
343 lines
8.7 KiB
TypeScript
343 lines
8.7 KiB
TypeScript
/**
|
|
* Socket.io Server for LocalGreenChain
|
|
*
|
|
* Provides real-time WebSocket communication with automatic
|
|
* fallback to SSE for environments that don't support WebSockets.
|
|
*/
|
|
|
|
import { Server as SocketIOServer, Socket } from 'socket.io';
|
|
import type { Server as HTTPServer } from 'http';
|
|
import type {
|
|
ClientToServerEvents,
|
|
ServerToClientEvents,
|
|
InterServerEvents,
|
|
SocketData,
|
|
RoomType,
|
|
TransparencyEventType,
|
|
TransparencyEvent,
|
|
} from './types';
|
|
import { getEventStream } from '../transparency/EventStream';
|
|
import { getEventRooms, isValidRoom, canJoinRoom, getDefaultRooms } from './rooms';
|
|
import * as crypto from 'crypto';
|
|
|
|
type TypedSocket = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
|
|
|
|
/**
|
|
* Socket.io server configuration
|
|
*/
|
|
interface SocketServerConfig {
|
|
cors?: {
|
|
origin: string | string[];
|
|
credentials?: boolean;
|
|
};
|
|
pingTimeout?: number;
|
|
pingInterval?: number;
|
|
}
|
|
|
|
/**
|
|
* Socket.io server wrapper for LocalGreenChain
|
|
*/
|
|
class RealtimeSocketServer {
|
|
private io: SocketIOServer<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData> | null = null;
|
|
private eventStreamSubscriptionId: string | null = null;
|
|
private connectedClients: Map<string, TypedSocket> = new Map();
|
|
private heartbeatInterval: NodeJS.Timeout | null = null;
|
|
|
|
/**
|
|
* Initialize the Socket.io server
|
|
*/
|
|
initialize(httpServer: HTTPServer, config: SocketServerConfig = {}): void {
|
|
if (this.io) {
|
|
console.log('[RealtimeSocketServer] Already initialized');
|
|
return;
|
|
}
|
|
|
|
this.io = new SocketIOServer(httpServer, {
|
|
path: '/api/socket',
|
|
cors: config.cors || {
|
|
origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001',
|
|
credentials: true,
|
|
},
|
|
pingTimeout: config.pingTimeout || 60000,
|
|
pingInterval: config.pingInterval || 25000,
|
|
transports: ['websocket', 'polling'],
|
|
});
|
|
|
|
this.setupMiddleware();
|
|
this.setupEventHandlers();
|
|
this.subscribeToEventStream();
|
|
this.startHeartbeat();
|
|
|
|
console.log('[RealtimeSocketServer] Initialized');
|
|
}
|
|
|
|
/**
|
|
* Set up authentication and rate limiting middleware
|
|
*/
|
|
private setupMiddleware(): void {
|
|
if (!this.io) return;
|
|
|
|
// Authentication middleware
|
|
this.io.use((socket, next) => {
|
|
const auth = socket.handshake.auth;
|
|
|
|
// Generate session ID if not provided
|
|
const sessionId = auth.sessionId || `sess_${crypto.randomBytes(8).toString('hex')}`;
|
|
|
|
// Attach data to socket
|
|
socket.data = {
|
|
userId: auth.userId,
|
|
sessionId,
|
|
connectedAt: Date.now(),
|
|
rooms: new Set<RoomType>(),
|
|
subscribedTypes: new Set<TransparencyEventType>(),
|
|
};
|
|
|
|
next();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set up socket event handlers
|
|
*/
|
|
private setupEventHandlers(): void {
|
|
if (!this.io) return;
|
|
|
|
this.io.on('connection', (socket: TypedSocket) => {
|
|
console.log(`[RealtimeSocketServer] Client connected: ${socket.id}`);
|
|
|
|
// Store connected client
|
|
this.connectedClients.set(socket.id, socket);
|
|
|
|
// Join default rooms
|
|
const defaultRooms = getDefaultRooms(socket.data.userId);
|
|
defaultRooms.forEach((room) => {
|
|
socket.join(room);
|
|
socket.data.rooms.add(room);
|
|
});
|
|
|
|
// Send connection established event
|
|
socket.emit('connection:established', {
|
|
socketId: socket.id,
|
|
serverTime: Date.now(),
|
|
});
|
|
|
|
// Handle room join
|
|
socket.on('room:join', (room, callback) => {
|
|
if (!isValidRoom(room)) {
|
|
callback?.(false);
|
|
return;
|
|
}
|
|
|
|
if (!canJoinRoom(Array.from(socket.data.rooms), room)) {
|
|
callback?.(false);
|
|
return;
|
|
}
|
|
|
|
socket.join(room);
|
|
socket.data.rooms.add(room);
|
|
socket.emit('room:joined', room);
|
|
callback?.(true);
|
|
});
|
|
|
|
// Handle room leave
|
|
socket.on('room:leave', (room, callback) => {
|
|
socket.leave(room);
|
|
socket.data.rooms.delete(room);
|
|
socket.emit('room:left', room);
|
|
callback?.(true);
|
|
});
|
|
|
|
// Handle type subscriptions
|
|
socket.on('subscribe:types', (types, callback) => {
|
|
types.forEach((type) => socket.data.subscribedTypes.add(type));
|
|
callback?.(true);
|
|
});
|
|
|
|
// Handle type unsubscriptions
|
|
socket.on('unsubscribe:types', (types, callback) => {
|
|
types.forEach((type) => socket.data.subscribedTypes.delete(type));
|
|
callback?.(true);
|
|
});
|
|
|
|
// Handle ping for latency measurement
|
|
socket.on('ping', (callback) => {
|
|
callback(Date.now());
|
|
});
|
|
|
|
// Handle recent events request
|
|
socket.on('events:recent', (limit, callback) => {
|
|
const eventStream = getEventStream();
|
|
const events = eventStream.getRecent(Math.min(limit, 100));
|
|
callback(events);
|
|
});
|
|
|
|
// Handle disconnect
|
|
socket.on('disconnect', (reason) => {
|
|
console.log(`[RealtimeSocketServer] Client disconnected: ${socket.id} (${reason})`);
|
|
this.connectedClients.delete(socket.id);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Subscribe to the EventStream for broadcasting events
|
|
*/
|
|
private subscribeToEventStream(): void {
|
|
const eventStream = getEventStream();
|
|
|
|
// Subscribe to all event types
|
|
this.eventStreamSubscriptionId = eventStream.subscribe(
|
|
eventStream.getAvailableEventTypes(),
|
|
(event) => {
|
|
this.broadcastEvent(event);
|
|
}
|
|
);
|
|
|
|
console.log('[RealtimeSocketServer] Subscribed to EventStream');
|
|
}
|
|
|
|
/**
|
|
* Broadcast an event to appropriate rooms
|
|
*/
|
|
private broadcastEvent(event: TransparencyEvent): void {
|
|
if (!this.io) return;
|
|
|
|
// Get rooms that should receive this event
|
|
const rooms = getEventRooms(event.type, event.data);
|
|
|
|
// Emit to each room
|
|
rooms.forEach((room) => {
|
|
this.io!.to(room).emit('event', event);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Start heartbeat to keep connections alive
|
|
*/
|
|
private startHeartbeat(): void {
|
|
if (this.heartbeatInterval) {
|
|
clearInterval(this.heartbeatInterval);
|
|
}
|
|
|
|
this.heartbeatInterval = setInterval(() => {
|
|
if (this.io) {
|
|
this.io.emit('system:heartbeat', Date.now());
|
|
}
|
|
}, 30000); // Every 30 seconds
|
|
}
|
|
|
|
/**
|
|
* Emit an event to specific rooms
|
|
*/
|
|
emitToRooms(rooms: RoomType[], eventName: keyof ServerToClientEvents, data: unknown): void {
|
|
if (!this.io) return;
|
|
|
|
rooms.forEach((room) => {
|
|
(this.io!.to(room) as any).emit(eventName, data);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Emit an event to a specific user
|
|
*/
|
|
emitToUser(userId: string, eventName: keyof ServerToClientEvents, data: unknown): void {
|
|
this.emitToRooms([`user:${userId}` as RoomType], eventName, data);
|
|
}
|
|
|
|
/**
|
|
* Emit a system message to all connected clients
|
|
*/
|
|
emitSystemMessage(type: 'info' | 'warning' | 'error', text: string): void {
|
|
if (!this.io) return;
|
|
|
|
this.io.emit('system:message', { type, text });
|
|
}
|
|
|
|
/**
|
|
* Get connected client count
|
|
*/
|
|
getConnectedCount(): number {
|
|
return this.connectedClients.size;
|
|
}
|
|
|
|
/**
|
|
* Get all connected socket IDs
|
|
*/
|
|
getConnectedSockets(): string[] {
|
|
return Array.from(this.connectedClients.keys());
|
|
}
|
|
|
|
/**
|
|
* Get server stats
|
|
*/
|
|
getStats(): {
|
|
connectedClients: number;
|
|
rooms: string[];
|
|
uptime: number;
|
|
} {
|
|
return {
|
|
connectedClients: this.connectedClients.size,
|
|
rooms: this.io ? Array.from(this.io.sockets.adapter.rooms.keys()) : [],
|
|
uptime: process.uptime(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Shutdown the server gracefully
|
|
*/
|
|
async shutdown(): Promise<void> {
|
|
if (this.heartbeatInterval) {
|
|
clearInterval(this.heartbeatInterval);
|
|
}
|
|
|
|
if (this.eventStreamSubscriptionId) {
|
|
const eventStream = getEventStream();
|
|
eventStream.unsubscribe(this.eventStreamSubscriptionId);
|
|
}
|
|
|
|
if (this.io) {
|
|
// Notify all clients
|
|
this.io.emit('system:message', {
|
|
type: 'warning',
|
|
text: 'Server is shutting down',
|
|
});
|
|
|
|
// Disconnect all clients
|
|
this.io.disconnectSockets(true);
|
|
|
|
// Close server
|
|
await new Promise<void>((resolve) => {
|
|
this.io!.close(() => {
|
|
console.log('[RealtimeSocketServer] Shutdown complete');
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
this.io = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the Socket.io server instance
|
|
*/
|
|
getIO(): SocketIOServer | null {
|
|
return this.io;
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
let socketServerInstance: RealtimeSocketServer | null = null;
|
|
|
|
/**
|
|
* Get the singleton socket server instance
|
|
*/
|
|
export function getSocketServer(): RealtimeSocketServer {
|
|
if (!socketServerInstance) {
|
|
socketServerInstance = new RealtimeSocketServer();
|
|
}
|
|
return socketServerInstance;
|
|
}
|
|
|
|
export { RealtimeSocketServer };
|
|
export default RealtimeSocketServer;
|