cntx-ui
Version:
File context management tool with web UI and MCP server for AI development workflows - bundle project files for LLM consumption
471 lines (391 loc) • 11.4 kB
JavaScript
/**
* WebSocket Manager for cntx-ui
* Handles real-time client communication and updates
*/
import { WebSocketServer } from 'ws';
export default class WebSocketManager {
constructor(bundleManager, configManager, options = {}) {
this.bundleManager = bundleManager;
this.configManager = configManager;
this.verbose = options.verbose || false;
this.clients = new Set();
this.wss = null;
}
// === WebSocket Server Setup ===
initialize(httpServer) {
this.wss = new WebSocketServer({ server: httpServer });
this.wss.on('connection', (ws) => {
this.handleConnection(ws);
});
if (this.verbose) {
console.log('🔌 WebSocket server initialized');
}
}
handleConnection(ws) {
// Add client to our set
this.clients.add(ws);
if (this.verbose) {
console.log(`📱 WebSocket client connected (${this.clients.size} total clients)`);
}
// Send initial update to the new client
this.sendUpdate(ws);
// Handle client disconnect
ws.on('close', () => {
this.clients.delete(ws);
if (this.verbose) {
console.log(`📱 WebSocket client disconnected (${this.clients.size} total clients)`);
}
});
// Handle client errors
ws.on('error', (error) => {
if (this.verbose) {
console.error('WebSocket client error:', error.message);
}
this.clients.delete(ws);
});
// Optional: Handle incoming messages from clients
ws.on('message', (message) => {
try {
const data = JSON.parse(message.toString());
this.handleClientMessage(ws, data);
} catch (error) {
if (this.verbose) {
console.error('Invalid WebSocket message:', error.message);
}
}
});
}
handleClientMessage(ws, data) {
// Handle different types of client messages
switch (data.type) {
case 'ping':
ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
break;
case 'request-update':
this.sendUpdate(ws);
break;
case 'subscribe':
// Future: Handle subscription to specific bundle updates
ws.subscriptions = data.bundles || [];
break;
default:
if (this.verbose) {
console.warn('Unknown WebSocket message type:', data.type);
}
}
}
// === Client Management ===
getClientCount() {
return this.clients.size;
}
getActiveClients() {
// Filter out clients that might be in a closed state
const activeClients = new Set();
this.clients.forEach(client => {
if (client.readyState === 1) { // WebSocket.OPEN
activeClients.add(client);
} else {
// Remove dead connections
this.clients.delete(client);
}
});
return activeClients;
}
// === Broadcasting Updates ===
broadcastUpdate() {
const activeClients = this.getActiveClients();
if (activeClients.size === 0) {
return; // No clients to update
}
if (this.verbose) {
console.log(`📡 Broadcasting update to ${activeClients.size} client(s)`);
}
activeClients.forEach(client => {
this.sendUpdate(client);
});
}
sendUpdate(client) {
if (client.readyState !== 1) { // Not WebSocket.OPEN
return;
}
try {
const updateData = this.prepareUpdateData();
client.send(JSON.stringify(updateData));
} catch (error) {
if (this.verbose) {
console.error('Failed to send WebSocket update:', error.message);
}
this.clients.delete(client);
}
}
prepareUpdateData() {
const bundles = this.configManager.getBundles();
const bundleData = Array.from(bundles.entries()).map(([name, bundle]) => ({
name,
changed: bundle.changed,
fileCount: bundle.files.length,
content: bundle.content.substring(0, 2000) + (bundle.content.length > 2000 ? '...' : ''),
files: bundle.files,
lastGenerated: bundle.generated,
size: bundle.size,
patterns: bundle.patterns
}));
return {
type: 'bundle-update',
timestamp: new Date().toISOString(),
bundles: bundleData,
serverStatus: {
uptime: process.uptime(),
scanning: this.bundleManager._isScanning || false,
totalFiles: this.bundleManager.fileSystemManager?.getAllFiles()?.length || 0
}
};
}
// === Targeted Updates ===
broadcastBundleUpdate(bundleName) {
const activeClients = this.getActiveClients();
if (activeClients.size === 0) {
return;
}
if (this.verbose) {
console.log(`📡 Broadcasting ${bundleName} bundle update to ${activeClients.size} client(s)`);
}
const bundle = this.configManager.getBundles().get(bundleName);
if (!bundle) {
return;
}
const updateData = {
type: 'bundle-specific-update',
timestamp: new Date().toISOString(),
bundleName,
bundle: {
name: bundleName,
changed: bundle.changed,
fileCount: bundle.files.length,
content: bundle.content.substring(0, 2000) + (bundle.content.length > 2000 ? '...' : ''),
files: bundle.files,
lastGenerated: bundle.generated,
size: bundle.size,
patterns: bundle.patterns
}
};
activeClients.forEach(client => {
try {
if (client.readyState === 1) {
client.send(JSON.stringify(updateData));
}
} catch (error) {
if (this.verbose) {
console.error('Failed to send bundle update:', error.message);
}
this.clients.delete(client);
}
});
}
broadcastFileChange(filename, eventType) {
const activeClients = this.getActiveClients();
if (activeClients.size === 0) {
return;
}
const updateData = {
type: 'file-change',
timestamp: new Date().toISOString(),
filename,
eventType,
message: `File ${eventType}: ${filename}`
};
activeClients.forEach(client => {
try {
if (client.readyState === 1) {
client.send(JSON.stringify(updateData));
}
} catch (error) {
if (this.verbose) {
console.error('Failed to send file change update:', error.message);
}
this.clients.delete(client);
}
});
}
broadcastStatusUpdate(status) {
const activeClients = this.getActiveClients();
if (activeClients.size === 0) {
return;
}
const updateData = {
type: 'status-update',
timestamp: new Date().toISOString(),
status
};
activeClients.forEach(client => {
try {
if (client.readyState === 1) {
client.send(JSON.stringify(updateData));
}
} catch (error) {
if (this.verbose) {
console.error('Failed to send status update:', error.message);
}
this.clients.delete(client);
}
});
}
// === Utility Methods ===
ping() {
const activeClients = this.getActiveClients();
const pingData = {
type: 'ping',
timestamp: new Date().toISOString(),
serverTime: Date.now()
};
activeClients.forEach(client => {
try {
if (client.readyState === 1) {
client.send(JSON.stringify(pingData));
}
} catch (error) {
this.clients.delete(client);
}
});
}
// === Cleanup ===
close() {
if (this.wss) {
if (this.verbose) {
console.log('🔌 Closing WebSocket server...');
}
// Close all client connections
this.clients.forEach(client => {
try {
if (client.readyState === 1) {
client.close(1000, 'Server shutting down');
}
} catch (error) {
if (this.verbose) {
console.error('Error closing WebSocket client:', error.message);
}
}
});
// Close the WebSocket server
this.wss.close(() => {
if (this.verbose) {
console.log('🔌 WebSocket server closed');
}
});
this.clients.clear();
}
}
// === Health Check ===
getHealthStatus() {
return {
connected: this.clients.size,
active: this.getActiveClients().size,
server: this.wss ? 'running' : 'stopped'
};
}
// === Event Handlers for Integration ===
onBundleGenerated(bundleName) {
this.broadcastBundleUpdate(bundleName);
this.broadcastBundleSyncCompleted(bundleName);
}
onBundlesGenerated() {
this.broadcastUpdate();
}
onConfigChanged() {
this.broadcastUpdate();
}
onFileChanged(filename, eventType) {
// Notify which bundles are affected by this file change
const affectedBundles = this.getAffectedBundles(filename);
affectedBundles.forEach(bundleName => {
this.broadcastBundleFileChanged(bundleName, filename);
});
this.broadcastFileChange(filename, eventType);
// Also broadcast bundle updates after a short delay to allow bundle regeneration
setTimeout(() => {
this.broadcastUpdate();
}, 500);
}
// === New Bundle Sync Event Handlers ===
onBundleSyncStarted(bundleName) {
const updateData = {
type: 'bundle-sync-started',
bundleName,
timestamp: new Date().toISOString()
};
this.broadcastToActiveClients(updateData);
}
onBundleSyncCompleted(bundleName) {
const updateData = {
type: 'bundle-sync-completed',
bundleName,
timestamp: new Date().toISOString()
};
this.broadcastToActiveClients(updateData);
}
onBundleSyncFailed(bundleName, error) {
const updateData = {
type: 'bundle-sync-failed',
bundleName,
error: error.message || error,
timestamp: new Date().toISOString()
};
this.broadcastToActiveClients(updateData);
}
broadcastBundleFileChanged(bundleName, filename) {
const updateData = {
type: 'bundle-file-changed',
bundleName,
filename,
timestamp: new Date().toISOString()
};
this.broadcastToActiveClients(updateData);
}
broadcastBundleSyncCompleted(bundleName) {
const updateData = {
type: 'bundle-sync-completed',
bundleName,
timestamp: new Date().toISOString()
};
this.broadcastToActiveClients(updateData);
}
// Helper method to broadcast to all active clients
broadcastToActiveClients(data) {
const activeClients = this.getActiveClients();
if (activeClients.size === 0) {
return;
}
activeClients.forEach(client => {
try {
if (client.readyState === 1) {
client.send(JSON.stringify(data));
}
} catch (error) {
if (this.verbose) {
console.error('Failed to send WebSocket update:', error.message);
}
this.clients.delete(client);
}
});
}
// Helper to find which bundles are affected by a file change
getAffectedBundles(filename) {
const bundles = this.configManager.getBundles();
const affectedBundles = [];
bundles.forEach((bundle, name) => {
const matchesBundle = bundle.patterns.some(pattern =>
this.bundleManager.fileSystemManager.matchesPattern(filename, pattern)
);
if (matchesBundle) {
affectedBundles.push(name);
}
});
return affectedBundles;
}
onHiddenFilesChanged() {
this.broadcastUpdate();
}
onIgnorePatternsChanged() {
this.broadcastUpdate();
}
}