localgreenchain/lib/realtime/socketServer.ts
Claude 7098335ce7
Add real-time updates system with Socket.io
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.
2025-11-23 03:51:51 +00:00

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;