Files
SuperCharged-Claude-Code-Up…/public/js/sse-client.js
uroma 55aafbae9a 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>
2026-01-22 14:43:05 +00:00

374 lines
10 KiB
JavaScript

/**
* SessionEventStream - Client-side SSE connection manager
*
* Manages Server-Sent Events connections for real-time session updates.
* Handles automatic reconnection with exponential backoff.
*/
class SessionEventStream {
/**
* Create a new SSE connection
* @param {string} sessionId - Session ID to connect to
* @param {Object} options - Configuration options
*/
constructor(sessionId, options = {}) {
this.sessionId = sessionId;
this.options = {
reconnectInterval: 1000, // Initial reconnect delay (ms)
maxReconnectInterval: 30000, // Max reconnect delay (ms)
reconnectDecay: 1.5, // Exponential backoff multiplier
maxReconnectAttempts: 10, // Maximum reconnection attempts
heartbeatTimeout: 60000, // Timeout for heartbeat (ms)
...options
};
this.eventSource = null;
this.reconnectAttempts = 0;
this.isConnected = false;
this.isReconnecting = false;
this.listeners = new Map();
this.lastHeartbeat = Date.now();
this.heartbeatCheckInterval = null;
console.log(`[SSE] Creating SessionEventStream for ${sessionId}`);
// Connect immediately
this.connect();
}
/**
* Establish SSE connection
*/
connect() {
if (this.eventSource) {
this.eventSource.close();
}
// Build URL with cache-busting
const url = `/api/session/${this.sessionId}/events?t=${Date.now()}`;
console.log(`[SSE] Connecting to ${url}`);
try {
this.eventSource = new EventSource(url);
this._setupEventHandlers();
} catch (error) {
console.error(`[SSE] Error creating EventSource:`, error);
this._handleReconnect();
}
}
/**
* Setup EventSource event handlers
* @private
*/
_setupEventHandlers() {
// Connection opened
this.eventSource.onopen = () => {
console.log(`[SSE] ✓ Connected to session ${this.sessionId}`);
this.isConnected = true;
this.isReconnecting = false;
this.reconnectAttempts = 0;
// Start heartbeat check
this.startHeartbeatCheck();
// Emit connected event
this.emit('connected', {
sessionId: this.sessionId,
timestamp: Date.now()
});
};
// Handle named events
this.eventSource.addEventListener('connected', (e) => {
const data = this._parseEvent(e);
console.log(`[SSE] Connected event received:`, data);
this.emit('connected', data);
this.lastHeartbeat = Date.now();
});
this.eventSource.addEventListener('session-output', (e) => {
const data = this._parseEvent(e);
this.emit('output', data);
this.lastHeartbeat = Date.now();
});
this.eventSource.addEventListener('session-error', (e) => {
const data = this._parseEvent(e);
console.error(`[SSE] Session error:`, data);
this.emit('error', data);
this.lastHeartbeat = Date.now();
});
this.eventSource.addEventListener('session-status', (e) => {
const data = this._parseEvent(e);
this.emit('status', data);
this.lastHeartbeat = Date.now();
});
this.eventSource.addEventListener('operations-detected', (e) => {
const data = this._parseEvent(e);
console.log(`[SSE] Operations detected:`, data.operations?.length || 0);
this.emit('operations-detected', data);
this.lastHeartbeat = Date.now();
});
this.eventSource.addEventListener('operations-executed', (e) => {
const data = this._parseEvent(e);
console.log(`[SSE] Operations executed:`, data.results?.length || 0);
this.emit('operations-executed', data);
this.lastHeartbeat = Date.now();
});
this.eventSource.addEventListener('operations-error', (e) => {
const data = this._parseEvent(e);
console.error(`[SSE] Operations error:`, data);
this.emit('operations-error', data);
this.lastHeartbeat = Date.now();
});
this.eventSource.addEventListener('approval-request', (e) => {
const data = this._parseEvent(e);
console.log(`[SSE] Approval request:`, data.command);
this.emit('approval-request', data);
this.lastHeartbeat = Date.now();
});
this.eventSource.addEventListener('approval-confirmed', (e) => {
const data = this._parseEvent(e);
console.log(`[SSE] Approval confirmed:`, data.approved);
this.emit('approval-confirmed', data);
this.lastHeartbeat = Date.now();
});
this.eventSource.addEventListener('approval-expired', (e) => {
const data = this._parseEvent(e);
console.log(`[SSE] Approval expired:`, data.approvalId);
this.emit('approval-expired', data);
this.lastHeartbeat = Date.now();
});
this.eventSource.addEventListener('command-sent', (e) => {
const data = this._parseEvent(e);
this.emit('command-sent', data);
this.lastHeartbeat = Date.now();
});
this.eventSource.addEventListener('shutdown', (e) => {
const data = this._parseEvent(e);
console.warn(`[SSE] Server shutdown`);
this.emit('shutdown', data);
this.disconnect();
});
// Default message handler (catch-all)
this.eventSource.onmessage = (e) => {
// Heartbeat comments (lines starting with ':') are handled automatically
if (e.data.startsWith(':')) {
this.lastHeartbeat = Date.now();
return;
}
try {
const data = this._parseEvent(e);
this.emit('message', data);
} catch (error) {
console.error('[SSE] Error parsing message:', error);
}
};
// Handle errors
this.eventSource.onerror = (e) => {
console.error(`[SSE] Connection error:`, e);
this.isConnected = false;
if (this.heartbeatCheckInterval) {
clearInterval(this.heartbeatCheckInterval);
this.heartbeatCheckInterval = null;
}
// Check if it's a permanent error (404, etc.)
if (this.eventSource.readyState === EventSource.CLOSED) {
console.error(`[SSE] Connection closed permanently`);
this.emit('disconnected', {
sessionId: this.sessionId,
permanent: true,
reason: 'connection_closed'
});
return;
}
// Attempt reconnect
this._handleReconnect();
};
}
/**
* Parse SSE event data
* @private
*/
_parseEvent(e) {
try {
return JSON.parse(e.data);
} catch (error) {
console.error('[SSE] Error parsing event data:', e.data);
return { raw: e.data };
}
}
/**
* Handle reconnection with exponential backoff
* @private
*/
_handleReconnect() {
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
console.error(`[SSE] Max reconnect attempts reached (${this.options.maxReconnectAttempts})`);
this.emit('disconnected', {
sessionId: this.sessionId,
permanent: true,
reason: 'max_reconnect_attempts',
attempts: this.reconnectAttempts
});
return;
}
if (this.isReconnecting) {
return; // Already reconnecting
}
this.isReconnecting = true;
// Calculate delay with exponential backoff
const delay = Math.min(
this.options.reconnectInterval * Math.pow(this.options.reconnectDecay, this.reconnectAttempts),
this.options.maxReconnectInterval
);
this.reconnectAttempts++;
console.log(`[SSE] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.options.maxReconnectAttempts})`);
this.emit('reconnecting', {
attempt: this.reconnectAttempts,
delay,
maxAttempts: this.options.maxReconnectAttempts
});
setTimeout(() => {
this.isReconnecting = false;
this.connect();
}, delay);
}
/**
* Start heartbeat monitoring
*/
startHeartbeatCheck() {
if (this.heartbeatCheckInterval) {
clearInterval(this.heartbeatCheckInterval);
}
this.heartbeatCheckInterval = setInterval(() => {
const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeat;
if (timeSinceLastHeartbeat > this.options.heartbeatTimeout) {
console.warn(`[SSE] Heartbeat timeout (${timeSinceLastHeartbeat}ms), forcing reconnect`);
this.eventSource.close();
this._handleReconnect();
}
}, this.options.heartbeatTimeout / 2);
}
/**
* Register event listener
* @param {string} event - Event name
* @param {Function} handler - Event handler function
*/
on(event, handler) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(handler);
}
/**
* Remove event listener
* @param {string} event - Event name
* @param {Function} handler - Event handler function
*/
off(event, handler) {
if (!this.listeners.has(event)) {
return;
}
const handlers = this.listeners.get(event);
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
/**
* Emit event to all registered listeners
* @private
*/
emit(event, data) {
if (!this.listeners.has(event)) {
return;
}
for (const handler of this.listeners.get(event)) {
try {
handler(data);
} catch (error) {
console.error(`[SSE] Error in ${event} handler:`, error);
}
}
}
/**
* Disconnect and cleanup
*/
disconnect() {
console.log(`[SSE] Disconnecting from session ${this.sessionId}`);
if (this.heartbeatCheckInterval) {
clearInterval(this.heartbeatCheckInterval);
this.heartbeatCheckInterval = null;
}
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
this.isConnected = false;
this.isReconnecting = false;
this.listeners.clear();
}
/**
* Get connection status
* @returns {Object} Connection status
*/
getStatus() {
return {
sessionId: this.sessionId,
isConnected: this.isConnected,
isReconnecting: this.isReconnecting,
reconnectAttempts: this.reconnectAttempts,
lastHeartbeat: this.lastHeartbeat,
readyState: this.eventSource ? this.eventSource.readyState : -1
};
}
}
// Export for use in UI
if (typeof module !== 'undefined' && module.exports) {
module.exports = SessionEventStream;
} else {
window.SessionEventStream = SessionEventStream;
}