Fix project isolation: Make loadChatHistory respect active project sessions

- Modified loadChatHistory() to check for active project before fetching all sessions
- When active project exists, use project.sessions instead of fetching from API
- Added detailed console logging to debug session filtering
- This prevents ALL sessions from appearing in every project's sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
uroma
2026-01-22 14:43:05 +00:00
Unverified
parent b82837aa5f
commit 55aafbae9a
6463 changed files with 1115462 additions and 4486 deletions

264
services/chat-monitor.js Normal file
View File

@@ -0,0 +1,264 @@
/**
* Real-Time Chat Monitor Service
*
* Monitors agentic chat behavior and detects failures in real-time.
* Triggers auto-fix pipeline when issues are detected.
*/
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
class ChatMonitorService extends EventEmitter {
constructor() {
super();
this.activeMonitors = new Map();
this.logsPath = path.join(__dirname, '../logs/chat-monitor');
this.ensureLogsDirectory();
this.startAutoFixListener();
}
ensureLogsDirectory() {
if (!fs.existsSync(this.logsPath)) {
fs.mkdirSync(this.logsPath, { recursive: true });
}
}
/**
* Start monitoring a session
*/
startSessionMonitor(sessionId) {
if (this.activeMonitors.has(sessionId)) {
console.log(`[ChatMonitor] Session ${sessionId} already monitored`);
return;
}
const monitor = {
sessionId,
startTime: Date.now(),
events: [],
lastActivity: Date.now(),
state: 'monitoring'
};
this.activeMonitors.set(sessionId, monitor);
console.log(`[ChatMonitor] Started monitoring session ${sessionId}`);
// Set up timeout for response detection
this.setupResponseTimeout(sessionId);
}
/**
* Log an event for a session
*/
logEvent(sessionId, eventType, data) {
const monitor = this.activeMonitors.get(sessionId);
if (!monitor) {
this.startSessionMonitor(sessionId);
return this.logEvent(sessionId, eventType, data);
}
const event = {
timestamp: Date.now(),
eventType,
data,
sessionId
};
monitor.events.push(event);
monitor.lastActivity = Date.now();
// Write to log file
this.writeLog(sessionId, event);
// Detect failures based on event type
this.detectFailures(sessionId, event);
console.log(`[ChatMonitor] [${sessionId}] ${eventType}:`, JSON.stringify(data).substring(0, 100));
}
/**
* Write event to log file
*/
writeLog(sessionId, event) {
const date = new Date().toISOString().split('T')[0];
const logFile = path.join(this.logsPath, `${date}-${sessionId}.log`);
const logLine = `[${new Date(event.timestamp).toISOString()}] [${event.eventType}] ${JSON.stringify(event.data)}\n`;
fs.appendFileSync(logFile, logLine, 'utf-8');
}
/**
* Set up response timeout detection
*/
setupResponseTimeout(sessionId) {
// Check for response after 30 seconds
setTimeout(() => {
this.checkResponseReceived(sessionId);
}, 30000);
}
/**
* Check if response was received
*/
checkResponseReceived(sessionId) {
const monitor = this.activeMonitors.get(sessionId);
if (!monitor) return;
const hasUserMessage = monitor.events.some(e => e.eventType === 'user_message');
const hasAIResponse = monitor.events.some(e => e.eventType === 'ai_response');
if (hasUserMessage && !hasAIResponse) {
this.logEvent(sessionId, 'failure_detected', {
reason: 'no_ai_response',
message: 'User message sent but no AI response received within 30 seconds'
});
this.emit('chat-failure', {
sessionId,
failureType: 'no_response',
events: monitor.events
});
}
}
/**
* Detect failures based on events
*/
detectFailures(sessionId, event) {
switch (event.eventType) {
case 'user_message_sent':
// Expect AI response within 30 seconds
break;
case 'claude_spawn_error':
this.emit('chat-failure', {
sessionId,
failureType: 'claude_spawn_failed',
error: event.data.error
});
break;
case 'json_parse_error':
this.emit('chat-failure', {
sessionId,
failureType: 'json_parse_failed',
error: event.data.error
});
break;
case 'sse_emit_error':
this.emit('chat-failure', {
sessionId,
failureType: 'sse_emit_failed',
error: event.data.error
});
break;
case 'browser_error':
// Analyze browser errors - only trigger on actual errors, not info logs
const msg = event.data.message || '';
const type = event.data.type || '';
// Only trigger on actual error types or SSE connection failures
const isError = type === 'console-error' ||
type === 'console-warn' ||
type === 'uncaughterror' ||
msg.includes('EventSource failed') ||
msg.includes('SSE connection failed') ||
msg.includes('Connection lost') ||
msg.includes('Failed to connect');
// Don't trigger on informational SSE logs (these indicate SSE is working!)
if (isError && (msg.includes('SSE') || msg.includes('EventSource'))) {
this.emit('chat-failure', {
sessionId,
failureType: 'browser_sse_error',
error: event.data.message
});
}
break;
}
}
/**
* Start auto-fix listener
*/
startAutoFixListener() {
this.on('chat-failure', async (failure) => {
console.log(`[ChatMonitor] 💥 Failure detected in session ${failure.sessionId}:`, failure.failureType);
this.logEvent(failure.sessionId, 'auto_fix_triggered', failure);
// Trigger auto-fix agent
await this.triggerAutoFix(failure);
});
}
/**
* Trigger auto-fix agent
*/
async triggerAutoFix(failure) {
console.log(`[ChatMonitor] 🔧 Triggering auto-fix for ${failure.failureType}`);
// Write failure to file for auto-fix agent to process
const failureFile = path.join(this.logsPath, `failure-${Date.now()}.json`);
fs.writeFileSync(failureFile, JSON.stringify(failure, null, 2));
// Launch auto-fix via background process
const { spawn } = require('child_process');
const autoFix = spawn('node', [__dirname + '../scripts/auto-fix.js', failureFile], {
detached: true,
stdio: 'ignore'
});
autoFix.unref();
console.log(`[ChatMonitor] 🚀 Auto-fix agent launched: ${autoFix.pid}`);
}
/**
* Stop monitoring a session
*/
stopSessionMonitor(sessionId) {
const monitor = this.activeMonitors.get(sessionId);
if (!monitor) return;
monitor.state = 'stopped';
monitor.endTime = Date.now();
monitor.duration = monitor.endTime - monitor.startTime;
// Write summary
this.writeSummary(sessionId, monitor);
this.activeMonitors.delete(sessionId);
console.log(`[ChatMonitor] Stopped monitoring session ${sessionId}`);
}
/**
* Write monitoring summary
*/
writeSummary(sessionId, monitor) {
const summaryPath = path.join(this.logsPath, `${sessionId}-summary.json`);
fs.writeFileSync(summaryPath, JSON.stringify(monitor, null, 2));
}
/**
* Get monitor status for all sessions
*/
getStatus() {
return {
activeMonitors: this.activeMonitors.size,
sessions: Array.from(this.activeMonitors.values()).map(m => ({
sessionId: m.sessionId,
state: m.state,
eventsCount: m.events.length,
duration: Date.now() - m.startTime
}))
};
}
}
// Singleton instance
const chatMonitor = new ChatMonitorService();
module.exports = chatMonitor;

View File

@@ -6,6 +6,14 @@ const os = require('os');
const { SYSTEM_PROMPT } = require('./system-prompt');
const { extractAllTags, generateOperationSummary, getDyadWriteTags } = require('./tag-parser');
const ResponseProcessor = require('./response-processor');
// ============================================================
// HYBRID APPROACH: Import global EventBus for SSE integration
// ============================================================
const eventBus = require('./event-bus');
// ============================================================
// REAL-TIME MONITORING: Import ChatMonitor
// ============================================================
const chatMonitor = require('./chat-monitor');
class ClaudeCodeService extends EventEmitter {
constructor(vaultPath) {
@@ -101,7 +109,7 @@ class ClaudeCodeService extends EventEmitter {
});
// Emit for real-time updates
this.emit('session-output', {
eventBus.emit('session-output', {
sessionId,
type: 'stdout',
content: text
@@ -118,7 +126,7 @@ class ClaudeCodeService extends EventEmitter {
content: text
});
this.emit('session-output', {
eventBus.emit('session-output', {
sessionId,
type: 'stderr',
content: text
@@ -169,6 +177,12 @@ class ClaudeCodeService extends EventEmitter {
console.log(`[ClaudeService] Sending command to session ${sessionId}:`, command);
// ============================================================
// REAL-TIME MONITORING: Start monitoring for this session
// ============================================================
chatMonitor.startSessionMonitor(sessionId);
chatMonitor.logEvent(sessionId, 'user_message_sent', { command, timestamp: Date.now() });
// Track command in context
session.context.messages.push({
role: 'user',
@@ -186,7 +200,7 @@ class ClaudeCodeService extends EventEmitter {
session.lastActivity = new Date().toISOString();
this.emit('command-sent', {
eventBus.emit('command-sent', {
sessionId,
command
});
@@ -195,36 +209,34 @@ class ClaudeCodeService extends EventEmitter {
const fullCommand = `${SYSTEM_PROMPT}\n\n${command}`;
// Spawn claude in -p (print) mode for this command
const claude = spawn('claude', ['-p', fullCommand], {
// NOTE: --output-format json is REQUIRED for non-interactive mode
const claude = spawn('claude', ['-p', fullCommand, '--output-format', 'json'], {
cwd: session.workingDir,
stdio: ['ignore', 'pipe', 'pipe'], // Explicitly set stdio to get stdout/stderr
env: {
...process.env,
TERM: 'xterm-256color'
}
},
timeout: 120000 // 2 minute timeout to prevent hanging
});
// ============================================================
// REAL-TIME MONITORING: Log Claude spawn
// ============================================================
chatMonitor.logEvent(sessionId, 'claude_spawned', {
pid: claude.pid,
command: 'claude -p <command> --output-format json',
timestamp: Date.now()
});
let output = '';
let stderrOutput = '';
let rawStdout = '';
claude.stdout.on('data', (data) => {
const text = data.toString();
rawStdout += text;
console.log(`[ClaudeService] [${sessionId}] stdout:`, text.substring(0, 100));
output += text;
// Add to output buffer
session.outputBuffer.push({
type: 'stdout',
timestamp: new Date().toISOString(),
content: text
});
// Emit for real-time updates
this.emit('session-output', {
sessionId,
type: 'stdout',
content: text
});
});
claude.stderr.on('data', (data) => {
@@ -238,7 +250,7 @@ class ClaudeCodeService extends EventEmitter {
content: text
});
this.emit('session-output', {
eventBus.emit('session-output', {
sessionId,
type: 'stderr',
content: text
@@ -248,6 +260,88 @@ class ClaudeCodeService extends EventEmitter {
claude.on('close', (code) => {
console.log(`[ClaudeService] [${sessionId}] Command completed with exit code ${code}`);
// Parse JSON output from Claude CLI
// The --output-format json flag returns: { "type": "result", "result": "...", ... }
try {
const jsonOutput = JSON.parse(rawStdout.trim());
if (jsonOutput.type === 'result' && jsonOutput.result) {
output = jsonOutput.result;
// ============================================================
// REAL-TIME MONITORING: Log JSON parsing success
// ============================================================
chatMonitor.logEvent(sessionId, 'json_parsed', {
resultLength: output.length,
timestamp: Date.now()
});
// Add to output buffer
session.outputBuffer.push({
type: 'stdout',
timestamp: new Date().toISOString(),
content: output
});
// Emit for real-time updates
eventBus.emit('session-output', {
sessionId,
type: 'stdout',
content: output
});
// ============================================================
// REAL-TIME MONITORING: Log SSE emit and AI response
// ============================================================
chatMonitor.logEvent(sessionId, 'sse_emit', {
eventType: 'session-output',
contentLength: output.length,
timestamp: Date.now()
});
chatMonitor.logEvent(sessionId, 'ai_response', {
content: output.substring(0, 200),
fullLength: output.length,
timestamp: Date.now()
});
console.log(`[ClaudeService] [${sessionId}] Parsed JSON output, result length: ${output.length}`);
} else if (jsonOutput.type === 'error') {
// Handle error responses
const errorMsg = jsonOutput.error || jsonOutput.message || 'Unknown error';
console.error(`[ClaudeService] [${sessionId}] Claude CLI error:`, errorMsg);
eventBus.emit('session-output', {
sessionId,
type: 'stderr',
content: `Error: ${errorMsg}`
});
} else {
// Fallback to raw output if JSON structure is unexpected
console.warn(`[ClaudeService] [${sessionId}] Unexpected JSON structure:`, jsonOutput);
output = rawStdout;
}
} catch (parseError) {
// If parsing fails, treat as raw text output
console.warn(`[ClaudeService] [${sessionId}] Failed to parse JSON output, using raw:`, parseError.message);
output = rawStdout;
if (output.trim()) {
// Emit raw output as fallback
session.outputBuffer.push({
type: 'stdout',
timestamp: new Date().toISOString(),
content: output
});
eventBus.emit('session-output', {
sessionId,
type: 'stdout',
content: output
});
}
}
// Add assistant response to context
if (output.trim()) {
session.context.messages.push({
@@ -262,7 +356,7 @@ class ClaudeCodeService extends EventEmitter {
if (tags.writes.length > 0 || tags.renames.length > 0 || tags.deletes.length > 0 || tags.dependencies.length > 0) {
const operations = generateOperationSummary(tags);
this.emit('operations-detected', {
eventBus.emit('operations-detected', {
sessionId,
response: output,
tags,
@@ -272,7 +366,7 @@ class ClaudeCodeService extends EventEmitter {
console.log(`[ClaudeService] Detected ${operations.length} operations requiring approval`);
}
this.emit('command-complete', {
eventBus.emit('command-complete', {
sessionId,
exitCode: code,
output
@@ -284,7 +378,7 @@ class ClaudeCodeService extends EventEmitter {
claude.on('error', (error) => {
console.error(`[ClaudeService] [${sessionId}] Process error:`, error);
this.emit('session-error', {
eventBus.emit('session-error', {
sessionId,
error: error.message
});
@@ -316,7 +410,7 @@ class ClaudeCodeService extends EventEmitter {
});
// Emit approval-request event for WebSocket to handle
this.emit('approval-request', {
eventBus.emit('approval-request', {
sessionId,
command,
explanation
@@ -478,15 +572,17 @@ class ClaudeCodeService extends EventEmitter {
/**
* List all sessions
* Sessions in memory are considered active/running even without a process
* because processes are spawned on-demand when commands are sent
*/
listSessions() {
return Array.from(this.sessions.values()).map(session => {
const metadata = this.calculateSessionMetadata(session);
// FIX: Only mark as running if process is actually alive
const isRunning = session.status === 'running' &&
session.process &&
!session.process.killed;
// FIX: Sessions in memory are considered active
// In the new architecture, processes are spawned on-demand
// A session is "running" if it exists in memory and hasn't been stopped
const isRunning = session.status === 'running';
return {
id: session.id,
@@ -777,7 +873,7 @@ class ClaudeCodeService extends EventEmitter {
}
);
this.emit('operations-executed', {
eventBus.emit('operations-executed', {
sessionId,
results
});
@@ -785,7 +881,7 @@ class ClaudeCodeService extends EventEmitter {
return results;
} catch (error) {
console.error(`[ClaudeService] Error executing operations:`, error);
this.emit('operations-error', {
eventBus.emit('operations-error', {
sessionId,
error: error.message
});

185
services/event-bus.js Normal file
View File

@@ -0,0 +1,185 @@
/**
* EventBus - Central event pub/sub system for session events
*
* Replaces callback-based event handling with a clean publish/subscribe pattern.
* All session events flow through the EventBus, allowing multiple subscribers
* to listen to the same events.
*/
const EventEmitter = require('events');
class EventBus extends EventEmitter {
constructor() {
super();
this.setMaxListeners(0); // Unlimited listeners for scalability
// Metrics for monitoring
this.metrics = {
eventsEmitted: 0,
eventsByType: new Map(),
listenerCounts: new Map()
};
}
/**
* Subscribe to an event type
* @param {string} eventType - Event type (e.g., 'session-output', 'session-error')
* @param {string|null} sessionId - Session ID to filter events (null for all sessions)
* @param {Function} handler - Event handler function
* @returns {Function} Unsubscribe function
*/
subscribe(eventType, sessionId, handler) {
const listenerId = `${eventType}-${sessionId || 'global'}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const wrappedHandler = (data) => {
// Filter by session ID if specified
if (sessionId !== null && data.sessionId !== sessionId) {
console.log(`[EventBus] Filtered event ${eventType}: subscribed=${sessionId}, data.sessionId=${data.sessionId}`);
return;
}
console.log(`[EventBus] Calling handler for ${eventType}, session ${sessionId}`);
try {
handler(data);
} catch (error) {
console.error(`[EventBus] Error in handler for ${eventType}:`, error);
console.error(`[EventBus] Handler:`, handler.name || 'anonymous');
console.error(`[EventBus] Data:`, data);
}
};
this.on(eventType, wrappedHandler);
// Track listener count for metrics
const key = `${eventType}-${sessionId || 'global'}`;
this.metrics.listenerCounts.set(key, (this.metrics.listenerCounts.get(key) || 0) + 1);
console.log(`[EventBus] Subscribed to ${eventType} for session ${sessionId || 'all'}. Total: ${this.metrics.listenerCounts.get(key)}`);
// Return unsubscribe function
return () => {
this.off(eventType, wrappedHandler);
const currentCount = this.metrics.listenerCounts.get(key) || 0;
this.metrics.listenerCounts.set(key, Math.max(0, currentCount - 1));
console.log(`[EventBus] Unsubscribed from ${eventType} for session ${sessionId || 'all'}. Remaining: ${this.metrics.listenerCounts.get(key)}`);
};
}
/**
* Emit an event to all subscribers
* @param {string} eventType - Event type
* @param {Object} data - Event data (must include sessionId for session-scoped events)
*/
emit(eventType, data = {}) {
this.metrics.eventsEmitted++;
this.metrics.eventsByType.set(eventType, (this.metrics.eventsByType.get(eventType) || 0) + 1);
// Add metadata to all events
const eventData = {
...data,
_timestamp: Date.now(),
_eventType: eventType
};
// Log important events
if (eventType.includes('error') || eventType.includes('expired')) {
console.error(`[EventBus] Emitting ${eventType}:`, data);
} else {
console.log(`[EventBus] Emitting ${eventType} for session ${data.sessionId || 'unknown'}`);
}
super.emit(eventType, eventData);
}
/**
* Subscribe to all events for a specific session
* @param {string} sessionId - Session ID
* @param {Function} handler - Handler for all session events
* @returns {Function} Unsubscribe function
*/
subscribeToSession(sessionId, handler) {
const eventTypes = [
'session-output',
'session-error',
'session-status',
'operations-detected',
'operations-executed',
'operations-error',
'approval-request',
'approval-confirmed',
'approval-expired',
'session-created',
'session-deleted',
'command-sent',
'command-complete',
'terminal-created',
'terminal-closed'
];
console.log(`[EventBus] Subscribing to all events for session ${sessionId}`);
const unsubscribers = eventTypes.map(type =>
this.subscribe(type, sessionId, handler)
);
// Return combined unsubscribe function
return () => {
console.log(`[EventBus] Unsubscribing from all events for session ${sessionId}`);
unsubscribers.forEach(unsub => unsub());
};
}
/**
* Get current metrics
* @returns {Object} Metrics object
*/
getMetrics() {
return {
eventsEmitted: this.metrics.eventsEmitted,
eventsByType: Object.fromEntries(this.metrics.eventsByType),
listenerCounts: Object.fromEntries(this.metrics.listenerCounts),
activeListeners: this.listenerCount('session-output') +
this.listenerCount('session-error') +
this.listenerCount('session-status') +
this.listenerCount('operations-detected')
};
}
/**
* Log metrics summary
*/
logMetrics() {
const metrics = this.getMetrics();
console.log('[EventBus] Metrics Summary:');
console.log(` Total events emitted: ${metrics.eventsEmitted}`);
console.log(` Events by type:`, metrics.eventsByType);
console.log(` Listener counts:`, metrics.listenerCounts);
console.log(` Active listeners: ${metrics.activeListeners}`);
}
/**
* Clear all listeners and reset metrics (useful for testing)
*/
clear() {
this.removeAllListeners();
this.metrics = {
eventsEmitted: 0,
eventsByType: new Map(),
listenerCounts: new Map()
};
console.log('[EventBus] Cleared all listeners and reset metrics');
}
}
// Singleton instance
const eventBus = new EventBus();
// Log metrics periodically (every 5 minutes in production)
if (process.env.NODE_ENV === 'production') {
setInterval(() => {
eventBus.logMetrics();
}, 5 * 60 * 1000);
}
module.exports = eventBus;

331
services/sse-manager.js Normal file
View File

@@ -0,0 +1,331 @@
/**
* SSEManager - Manages Server-Sent Events connections
*
* Handles SSE connection lifecycle, heartbeat, and event delivery
* for real-time streaming of session events to clients.
*/
const eventBus = require('./event-bus');
class SSEManager {
constructor() {
// Map: sessionId -> Set of response objects
this.connections = new Map();
// Heartbeat configuration
this.heartbeatInterval = 30000; // 30 seconds
this.heartbeatTimers = new Map();
// Connection tracking
this.totalConnectionsCreated = 0;
this.totalConnectionsClosed = 0;
}
/**
* Add SSE connection for a session
* @param {string} sessionId - Session ID
* @param {Object} res - Express response object
* @param {Object} req - Express request object
*/
addConnection(sessionId, res, req) {
// Setup SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
res.setHeader('Connection', 'keep-alive');
// Flush headers to establish connection
res.flushHeaders();
// Add connection to set
if (!this.connections.has(sessionId)) {
this.connections.set(sessionId, new Set());
}
const connectionSet = this.connections.get(sessionId);
connectionSet.add(res);
this.totalConnectionsCreated++;
console.log(`[SSEManager] ✓ Connection added for session ${sessionId}`);
console.log(`[SSEManager] Total connections for ${sessionId}: ${connectionSet.size}`);
console.log(`[SSEManager] Total active sessions: ${this.connections.size}`);
console.log(`[SSEManager] Total connections created: ${this.totalConnectionsCreated}`);
// Get client info
const clientIp = req.ip || req.socket.remoteAddress;
const userAgent = req.get('User-Agent') || 'unknown';
// Send initial connection event
this.sendEvent(sessionId, res, {
type: 'connected',
sessionId,
timestamp: Date.now(),
message: 'SSE connection established',
client: {
ip: clientIp,
userAgent: userAgent.substring(0, 100) // Truncate very long user agents
}
});
// Start heartbeat for this connection
this.startHeartbeat(sessionId, res);
// Subscribe to all session events from EventBus
const unsubscribe = eventBus.subscribeToSession(sessionId, (eventData) => {
console.log(`[SSEManager] Received event for session ${sessionId}:`, eventData.type, eventData._eventType);
this.sendEvent(sessionId, res, eventData);
});
// Handle client disconnect
req.on('close', () => {
this.removeConnection(sessionId, res, unsubscribe);
});
// Log initial session status
eventBus.emit('session-status', {
sessionId,
status: 'sse-connected',
connections: connectionSet.size
});
}
/**
* Send SSE event to a specific connection
* @param {string} sessionId - Session ID
* @param {Object} res - Response object
* @param {Object} data - Event data
* @returns {boolean} True if sent successfully
*/
sendEvent(sessionId, res, data) {
// Check if connection is still alive
if (res.destroyed || res.writableEnded || res.closed) {
console.log(`[SSEManager] Skipping send to ${sessionId} - connection closed`);
return false;
}
try {
// Use the EventBus event type (_eventType) for the SSE event name
// This ensures frontend receives events like 'session-output' not 'stdout'
const eventName = data._eventType || data.type || 'message';
const eventData = JSON.stringify(data);
console.log(`[SSEManager] SENDING to ${sessionId}: event=${eventName}, _eventType=${data._eventType}, type=${data.type}`);
// SSE format: event: <name>\ndata: <json>\nid: <id>\n\n
res.write(`event: ${eventName}\n`);
res.write(`data: ${eventData}\n`);
res.write(`id: ${Date.now()}\n`);
res.write('\n');
console.log(`[SSEManager] ✓ Sent to ${sessionId}: ${eventName}`);
return true;
} catch (error) {
console.error(`[SSEManager] ✗ Error sending event to ${sessionId}:`, error.message);
return false;
}
}
/**
* Send event to all connections for a session
* @param {string} sessionId - Session ID
* @param {Object} data - Event data
* @returns {number} Number of clients the event was sent to
*/
broadcastToSession(sessionId, data) {
const connectionSet = this.connections.get(sessionId);
if (!connectionSet) {
return 0;
}
let sentCount = 0;
const deadConnections = [];
for (const res of connectionSet) {
if (!this.sendEvent(sessionId, res, data)) {
deadConnections.push(res);
} else {
sentCount++;
}
}
// Clean up dead connections
deadConnections.forEach(res => {
console.warn(`[SSEManager] Removing dead connection for session ${sessionId}`);
connectionSet.delete(res);
this.totalConnectionsClosed++;
});
if (deadConnections.length > 0) {
console.log(`[SSEManager] Cleaned up ${deadConnections.length} dead connection(s) for ${sessionId}`);
}
return sentCount;
}
/**
* Start heartbeat for a connection
* @param {string} sessionId - Session ID
* @param {Object} res - Response object
*/
startHeartbeat(sessionId, res) {
const connectionId = `${sessionId}-${res.socket?.remotePort || Math.random()}`;
const timerId = setInterval(() => {
if (res.destroyed || res.writableEnded || res.closed) {
clearInterval(timerId);
this.heartbeatTimers.delete(connectionId);
return;
}
// Send heartbeat comment (keeps connection alive)
try {
res.write(': heartbeat\n\n');
} catch (error) {
console.error(`[SSEManager] Heartbeat failed for ${sessionId}:`, error.message);
clearInterval(timerId);
this.heartbeatTimers.delete(connectionId);
this.removeConnection(sessionId, res, () => {});
}
}, this.heartbeatInterval);
this.heartbeatTimers.set(connectionId, timerId);
console.log(`[SSEManager] Heartbeat started for ${sessionId} (${this.heartbeatInterval}ms interval)`);
}
/**
* Remove SSE connection
* @param {string} sessionId - Session ID
* @param {Object} res - Response object
* @param {Function} unsubscribe - Unsubscribe function from EventBus
*/
removeConnection(sessionId, res, unsubscribe) {
const connectionSet = this.connections.get(sessionId);
if (connectionSet) {
connectionSet.delete(res);
if (connectionSet.size === 0) {
this.connections.delete(sessionId);
console.log(`[SSEManager] No more connections for session ${sessionId}, removing from tracking`);
}
this.totalConnectionsClosed++;
}
console.log(`[SSEManager] ✗ Connection removed for session ${sessionId}`);
console.log(`[SSEManager] Remaining connections for ${sessionId}: ${connectionSet?.size || 0}`);
// Stop heartbeat
const connectionId = `${sessionId}-${res.socket?.remotePort || 'unknown'}`;
const timerId = this.heartbeatTimers.get(connectionId);
if (timerId) {
clearInterval(timerId);
this.heartbeatTimers.delete(connectionId);
console.log(`[SSEManager] Heartbeat stopped for ${sessionId}`);
}
// Unsubscribe from events
if (unsubscribe) {
unsubscribe();
}
// Log final status
console.log(`[SSEManager] Total connections created: ${this.totalConnectionsCreated}`);
console.log(`[SSEManager] Total connections closed: ${this.totalConnectionsClosed}`);
console.log(`[SSEManager] Active connections: ${this.totalConnectionsCreated - this.totalConnectionsClosed}`);
}
/**
* Get connection count for a session
* @param {string} sessionId - Session ID
* @returns {number} Number of active connections
*/
getConnectionCount(sessionId) {
return this.connections.get(sessionId)?.size || 0;
}
/**
* Get all active session IDs
* @returns {Array<string>} Array of session IDs with active connections
*/
getActiveSessions() {
return Array.from(this.connections.keys());
}
/**
* Get detailed connection stats
* @returns {Object} Connection statistics
*/
getStats() {
const sessionStats = {};
for (const [sessionId, connectionSet] of this.connections.entries()) {
sessionStats[sessionId] = connectionSet.size;
}
return {
totalSessions: this.connections.size,
totalConnections: Array.from(this.connections.values())
.reduce((sum, set) => sum + set.size, 0),
sessions: sessionStats,
totalCreated: this.totalConnectionsCreated,
totalClosed: this.totalConnectionsClosed,
activeHeartbeats: this.heartbeatTimers.size
};
}
/**
* Clean up all connections (for shutdown)
*/
cleanup() {
console.log('[SSEManager] Cleaning up all connections...');
let closedCount = 0;
for (const [sessionId, connectionSet] of this.connections.entries()) {
for (const res of connectionSet) {
try {
res.write('event: shutdown\ndata: {"message":"Server shutting down"}\n\n');
res.end();
closedCount++;
} catch (error) {
console.error(`[SSEManager] Error closing connection for ${sessionId}:`, error.message);
}
}
}
// Clear all heartbeat timers
for (const timerId of this.heartbeatTimers.values()) {
clearInterval(timerId);
}
this.connections.clear();
this.heartbeatTimers.clear();
console.log(`[SSEManager] Cleanup complete. Closed ${closedCount} connection(s)`);
}
/**
* Log connection stats
*/
logStats() {
const stats = this.getStats();
console.log('[SSEManager] Connection Stats:');
console.log(` Active sessions: ${stats.totalSessions}`);
console.log(` Total connections: ${stats.totalConnections}`);
console.log(` Sessions:`, stats.sessions);
console.log(` Total created: ${stats.totalCreated}`);
console.log(` Total closed: ${stats.totalClosed}`);
console.log(` Active heartbeats: ${stats.activeHeartbeats}`);
}
}
// Singleton instance
const sseManager = new SSEManager();
// Log stats periodically (every 5 minutes in production)
if (process.env.NODE_ENV === 'production') {
setInterval(() => {
sseManager.logStats();
}, 5 * 60 * 1000);
}
module.exports = sseManager;