cui-server
Version:
Web UI Agent Platform based on Claude Code
580 lines • 27.8 kB
JavaScript
import express from 'express';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { displayServerStartup } from './utils/server-startup.js';
// Get the directory of this module for serving static files
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
import { ClaudeProcessManager } from './services/claude-process-manager.js';
import { StreamManager } from './services/stream-manager.js';
import { ClaudeHistoryReader } from './services/claude-history-reader.js';
import { PermissionTracker } from './services/permission-tracker.js';
import { MCPConfigGenerator } from './services/mcp-config-generator.js';
import { FileSystemService } from './services/file-system-service.js';
import { ConfigService } from './services/config-service.js';
import { SessionInfoService } from './services/session-info-service.js';
import { ConversationStatusManager } from './services/conversation-status-manager.js';
import { WorkingDirectoriesService } from './services/working-directories-service.js';
import { ToolMetricsService } from './services/ToolMetricsService.js';
import { NotificationService } from './services/notification-service.js';
import { WebPushService } from './services/web-push-service.js';
import { geminiService } from './services/gemini-service.js';
import { ClaudeRouterService } from './services/claude-router-service.js';
import { CUIError } from './types/index.js';
import { createLogger } from './services/logger.js';
import { createConversationRoutes } from './routes/conversation.routes.js';
import { createSystemRoutes } from './routes/system.routes.js';
import { createPermissionRoutes } from './routes/permission.routes.js';
import { createFileSystemRoutes } from './routes/filesystem.routes.js';
import { createLogRoutes } from './routes/log.routes.js';
import { createStreamingRoutes } from './routes/streaming.routes.js';
import { createWorkingDirectoriesRoutes } from './routes/working-directories.routes.js';
import { createConfigRoutes } from './routes/config.routes.js';
import { createGeminiRoutes } from './routes/gemini.routes.js';
import { createNotificationsRoutes } from './routes/notifications.routes.js';
import { errorHandler } from './middleware/error-handler.js';
import { requestLogger } from './middleware/request-logger.js';
import { createCorsMiddleware } from './middleware/cors-setup.js';
import { queryParser } from './middleware/query-parser.js';
import { authMiddleware, createAuthMiddleware } from './middleware/auth.js';
// ViteExpress will be imported dynamically in initialize() if needed
let ViteExpress;
/**
* Main CUI server class
*/
export class CUIServer {
app;
server;
processManager;
streamManager;
historyReader;
statusTracker;
permissionTracker;
mcpConfigGenerator;
fileSystemService;
configService;
sessionInfoService;
conversationStatusManager;
workingDirectoriesService;
toolMetricsService;
notificationService;
webPushService;
routerService;
logger;
port;
host;
configOverrides;
constructor(configOverrides) {
this.app = express();
this.configOverrides = configOverrides;
this.logger = createLogger('CUIServer');
// TEST: Add debug log right at the start
this.logger.debug('🔍 TEST: CUIServer constructor started - this should be visible if debug logging works');
// Initialize config service first
this.configService = ConfigService.getInstance();
// Will be set after config is loaded
this.port = 0;
this.host = '';
this.logger.debug('Initializing CUIServer', {
nodeEnv: process.env.NODE_ENV,
configOverrides
});
// Initialize services
this.logger.debug('Initializing services');
this.sessionInfoService = new SessionInfoService();
this.historyReader = new ClaudeHistoryReader(this.sessionInfoService);
// Create a single instance of ConversationStatusManager for both statusTracker and conversationStatusManager
this.conversationStatusManager = new ConversationStatusManager();
this.statusTracker = this.conversationStatusManager; // Use the same instance for backward compatibility
this.toolMetricsService = new ToolMetricsService();
this.fileSystemService = new FileSystemService();
this.processManager = new ClaudeProcessManager(this.historyReader, this.statusTracker, undefined, undefined, this.toolMetricsService, this.sessionInfoService, this.fileSystemService);
this.streamManager = new StreamManager();
this.permissionTracker = new PermissionTracker();
this.mcpConfigGenerator = new MCPConfigGenerator(this.fileSystemService);
this.workingDirectoriesService = new WorkingDirectoriesService(this.historyReader, this.logger);
this.notificationService = new NotificationService();
this.webPushService = WebPushService.getInstance();
// Wire up notification service
this.processManager.setNotificationService(this.notificationService);
this.permissionTracker.setNotificationService(this.notificationService);
this.permissionTracker.setConversationStatusManager(this.conversationStatusManager);
this.permissionTracker.setHistoryReader(this.historyReader);
this.logger.debug('Services initialized successfully');
this.setupMiddleware();
// Routes will be set up in start() to allow tests to override services
this.setupProcessManagerIntegration();
this.setupPermissionTrackerIntegration();
this.processManager.setConversationStatusManager(this.conversationStatusManager);
}
/**
* Get the Express app instance
*/
getApp() {
return this.app;
}
/**
* Get the configured port
*/
getPort() {
return this.port;
}
/**
* Get the configured host
*/
getHost() {
return this.host;
}
/**
* Initialize services without starting the HTTP server
*/
async initialize() {
this.logger.debug('Initialize method called');
try {
// Initialize configuration first
this.logger.debug('Initializing configuration');
await this.configService.initialize();
const config = this.configService.getConfig();
// Initialize session info service
this.logger.debug('Initializing session info service');
await this.sessionInfoService.initialize();
this.logger.debug('Session info service initialized successfully');
this.logger.debug('Initializing Gemini service');
await geminiService.initialize();
this.logger.debug('Gemini service initialized successfully');
// Initialize router service if configured
await this.initializeOrReloadRouter(config);
// Apply overrides if provided (for tests and CLI options)
this.port = this.configOverrides?.port ?? config.server.port;
this.host = this.configOverrides?.host ?? config.server.host;
this.logger.info('Configuration loaded', {
machineId: config.machine_id,
port: this.port,
host: this.host,
overrides: this.configOverrides ? Object.keys(this.configOverrides) : []
});
// Set up routes after services are initialized
// This allows tests to override services before routes are created
this.logger.debug('Setting up routes');
this.setupRoutes();
// Generate MCP config before starting server
try {
const mcpConfigPath = await this.mcpConfigGenerator.generateConfig(this.port);
this.processManager.setMCPConfigPath(mcpConfigPath);
this.logger.debug('MCP config generated and set', { path: mcpConfigPath });
}
catch (error) {
const isTestEnv = process.env.NODE_ENV === 'test';
if (isTestEnv) {
this.logger.warn('MCP config generation failed in test environment, proceeding without MCP', {
error: error instanceof Error ? error.message : String(error)
});
// Don't set MCP config path - conversations will run without MCP
}
else {
this.logger.error('MCP config generation failed in production environment', {
error: error instanceof Error ? error.message : String(error)
});
throw new CUIError('MCP_CONFIG_REQUIRED', `MCP server files are required in production but not found: ${error instanceof Error ? error.message : String(error)}`, 500);
}
}
// Display server startup information
displayServerStartup({
host: this.host,
port: this.port,
authToken: this.configOverrides?.token ?? config.authToken,
skipAuthToken: this.configOverrides?.skipAuthToken,
logger: this.logger
});
// Subscribe to configuration changes to hot-reload router when needed
this.configService.onChange(async (newConfig) => {
try {
await this.initializeOrReloadRouter(newConfig);
}
catch (error) {
this.logger.error('Failed to reload router after config change', error);
}
});
}
catch (error) {
this.logger.error('Failed to initialize server:', error, {
errorType: error instanceof Error ? error.constructor.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error)
});
if (error instanceof CUIError) {
throw error;
}
else {
throw new CUIError('SERVER_INIT_FAILED', `Server initialization failed: ${error}`, 500);
}
}
}
/**
* Start the server
*/
async start() {
this.logger.debug('Start method called');
try {
// Initialize all services
await this.initialize();
// Start Express server
const isDev = process.env.NODE_ENV === 'development';
this.logger.debug('Creating HTTP server listener', {
useViteExpress: isDev,
environment: process.env.NODE_ENV
});
// Import ViteExpress dynamically if in development mode
if (isDev && !ViteExpress) {
const viteExpressModule = await import('vite-express');
ViteExpress = viteExpressModule.default;
}
await new Promise((resolve, reject) => {
// Use ViteExpress only in development
if (isDev && ViteExpress) {
try {
this.server = this.app.listen(this.port, this.host, () => {
this.logger.debug('Server successfully bound to port (dev mode)', {
port: this.port,
host: this.host,
address: this.server?.address()
});
resolve();
});
// Configure ViteExpress for development
ViteExpress.config({
mode: 'development',
viteConfigFile: 'vite.config.mts'
});
ViteExpress.bind(this.app, this.server);
this.logger.info(`CUI development server running on http://${this.host}:${this.port}`);
}
catch (error) {
this.logger.error('Failed to start ViteExpress server', error);
reject(error);
}
}
else {
// Production/test mode - regular Express server
this.server = this.app.listen(this.port, this.host, () => {
this.logger.debug('Server successfully bound to port', {
port: this.port,
host: this.host,
address: this.server?.address(),
mode: process.env.NODE_ENV || 'production'
});
resolve();
});
}
if (this.server) {
this.server.on('error', (error) => {
this.logger.error('Failed to start HTTP server:', error, {
errorCode: error.code,
errorSyscall: error.syscall,
port: this.port,
host: this.host
});
reject(new CUIError('HTTP_SERVER_START_FAILED', `Failed to start HTTP server: ${error.message}`, 500));
});
}
});
}
catch (error) {
this.logger.error('Failed to start server:', error, {
errorType: error instanceof Error ? error.constructor.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error)
});
// Attempt cleanup on startup failure
await this.cleanup();
if (error instanceof CUIError) {
throw error;
}
else {
throw new CUIError('SERVER_START_FAILED', `Server startup failed: ${error}`, 500);
}
}
}
/**
* Stop the server gracefully
*/
async stop() {
this.logger.debug('Stop method called', {
hasServer: !!this.server,
activeSessions: this.processManager.getActiveSessions().length,
connectedClients: this.streamManager.getTotalClientCount()
});
if (this.routerService) {
await this.routerService.stop();
}
// Stop accepting new connections
if (this.server) {
// Since Node v18.2.0, closeAllConnections is available to close all connections.
if (typeof this.server.closeAllConnections === 'function') {
this.server.closeAllConnections();
}
}
// Stop all active Claude processes
const activeSessions = this.processManager.getActiveSessions();
if (activeSessions.length > 0) {
this.logger.info(`Stopping ${activeSessions.length} active sessions...`);
this.logger.debug('Active sessions to stop', { sessionIds: activeSessions });
const stopResults = await Promise.allSettled(activeSessions.map(streamingId => this.processManager.stopConversation(streamingId)
.catch(error => this.logger.error(`Error stopping session ${streamingId}:`, error))));
this.logger.debug('Session stop results', {
total: stopResults.length,
fulfilled: stopResults.filter(r => r.status === 'fulfilled').length,
rejected: stopResults.filter(r => r.status === 'rejected').length
});
}
// Disconnect all streaming clients
this.logger.debug('Disconnecting all streaming clients');
this.streamManager.disconnectAll();
// Clean up MCP config
this.logger.debug('Cleaning up MCP config');
this.mcpConfigGenerator.cleanup();
// Only close server in test environment
if (process.env.NODE_ENV === 'test' && this.server) {
this.logger.debug('Closing HTTP server (test environment)');
await new Promise((resolve) => {
this.server.close(() => {
this.logger.info('HTTP server closed successfully');
resolve();
});
});
}
}
/**
* Cleanup resources during failed startup
*/
async cleanup() {
this.logger.info('Performing cleanup after startup failure...');
this.logger.debug('Cleanup initiated', {
hasServer: !!this.server,
hasActiveStreams: this.streamManager.getTotalClientCount() > 0
});
try {
// Close HTTP server if it was started
if (this.server) {
await new Promise((resolve) => {
this.server.close(() => {
this.logger.info('HTTP server closed during cleanup');
resolve();
});
});
}
// Disconnect streaming clients
this.streamManager.disconnectAll();
this.logger.info('Cleanup completed');
}
catch (error) {
this.logger.error('Error during cleanup:', error, {
errorType: error instanceof Error ? error.constructor.name : typeof error
});
}
}
setupMiddleware() {
this.app.use(createCorsMiddleware());
this.app.use(express.json({ limit: '10mb' }));
// Static file serving
const isDev = process.env.NODE_ENV === 'development';
if (!isDev) {
// In production/test, serve built static files
// In production, __dirname will be /path/to/node_modules/cui-server/dist
// We need to serve from dist/web
const staticPath = path.join(__dirname, 'web');
this.logger.debug('Serving static files from', { path: staticPath });
this.app.use(express.static(staticPath));
}
// In development, ViteExpress handles static file serving
// Request logging
this.app.use(requestLogger);
// Query parameter parsing - convert strings to proper types
this.app.use(queryParser);
}
setupRoutes() {
// System routes (includes health check) - before auth
this.app.use('/api/system', createSystemRoutes(this.processManager, this.historyReader));
this.app.use('/', createSystemRoutes(this.processManager, this.historyReader)); // For /health at root
// Permission routes - before auth (needed for MCP server communication)
this.app.use('/api/permissions', createPermissionRoutes(this.permissionTracker));
// Notifications routes - before auth (needed for service worker subscription on first load)
this.app.use('/api/notifications', createNotificationsRoutes(this.webPushService));
// Apply auth middleware to all other API routes unless skipAuthToken is set
if (!this.configOverrides?.skipAuthToken) {
if (this.configOverrides?.token) {
// Use custom auth middleware with token override
this.app.use('/api', createAuthMiddleware(this.configOverrides.token));
this.logger.info('Using custom authentication token from CLI');
}
else {
// Use default auth middleware
this.app.use('/api', authMiddleware);
}
}
else {
this.logger.warn('Authentication middleware is disabled - API endpoints are not protected!');
}
// API routes
this.app.use('/api/conversations', createConversationRoutes(this.processManager, this.historyReader, this.statusTracker, this.sessionInfoService, this.conversationStatusManager, this.toolMetricsService));
this.app.use('/api/filesystem', createFileSystemRoutes(this.fileSystemService));
this.app.use('/api/logs', createLogRoutes());
this.app.use('/api/stream', createStreamingRoutes(this.streamManager));
this.app.use('/api/working-directories', createWorkingDirectoriesRoutes(this.workingDirectoriesService));
this.app.use('/api/config', createConfigRoutes(this.configService));
this.app.use('/api/gemini', createGeminiRoutes(geminiService));
// React Router catch-all - must be after all API routes
const isDev = process.env.NODE_ENV === 'development';
if (!isDev) {
// In production/test, serve index.html for all non-API routes
this.app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'web', 'index.html'));
});
}
// In development, ViteExpress handles React routing
// Error handling - MUST be last
this.app.use(errorHandler);
}
setupProcessManagerIntegration() {
this.logger.debug('Setting up ProcessManager integration with StreamManager');
// Set up tool metrics service to listen to claude messages
this.toolMetricsService.listenToClaudeMessages(this.processManager);
// Forward Claude messages to stream
this.processManager.on('claude-message', ({ streamingId, message }) => {
this.logger.debug('Received claude-message event', {
streamingId,
messageType: message?.type,
messageSubtype: message?.subtype,
hasContent: !!message?.content,
contentLength: message?.content?.length || 0,
messageKeys: message ? Object.keys(message) : []
});
// Skip broadcasting system init messages as they're now included in API response
if (message && message.type === 'system' && message.subtype === 'init') {
this.logger.debug('Skipping broadcast of system init message (included in API response)', {
streamingId,
sessionId: message.session_id
});
return;
}
// Stream other Claude messages as normal
this.logger.debug('Broadcasting message to StreamManager', {
streamingId,
messageType: message?.type,
messageSubtype: message?.subtype
});
this.streamManager.broadcast(streamingId, message);
});
// Handle process closure
this.processManager.on('process-closed', ({ streamingId, code }) => {
this.logger.debug('Received process-closed event, closing StreamManager session', {
streamingId,
exitCode: code,
clientCount: this.streamManager.getClientCount(streamingId),
wasSuccessful: code === 0
});
// Unregister session from status tracker
this.logger.debug('Unregistering session from status tracker', { streamingId });
this.statusTracker.unregisterActiveSession(streamingId);
// Clean up conversation context (handled automatically in unregisterActiveSession)
// Clean up permissions for this streaming session
const removedCount = this.permissionTracker.removePermissionsByStreamingId(streamingId);
if (removedCount > 0) {
this.logger.debug('Cleaned up permissions for closed session', {
streamingId,
removedPermissions: removedCount
});
}
if (code === 0) {
// Session completion notification removed
}
this.streamManager.closeSession(streamingId);
});
// Handle process errors
this.processManager.on('process-error', ({ streamingId, error }) => {
this.logger.debug('Received process-error event, forwarding to StreamManager', {
streamingId,
error,
errorLength: error?.toString().length || 0,
clientCount: this.streamManager.getClientCount(streamingId)
});
// Unregister session from status tracker on error
this.logger.debug('Unregistering session from status tracker due to error', { streamingId });
this.statusTracker.unregisterActiveSession(streamingId);
// Clean up conversation context on error (handled automatically in unregisterActiveSession)
const errorEvent = {
type: 'error',
error: error.toString(),
streamingId: streamingId,
timestamp: new Date().toISOString()
};
this.logger.debug('Broadcasting error event to clients', {
streamingId,
errorEventKeys: Object.keys(errorEvent)
});
this.streamManager.broadcast(streamingId, errorEvent);
});
this.logger.debug('ProcessManager integration setup complete', {
totalEventListeners: this.processManager.listenerCount('claude-message') +
this.processManager.listenerCount('process-closed') +
this.processManager.listenerCount('process-error')
});
}
setupPermissionTrackerIntegration() {
this.logger.debug('Setting up PermissionTracker integration');
// Forward permission events to stream
this.permissionTracker.on('permission_request', (request) => {
this.logger.debug('Permission request event received', {
id: request.id,
toolName: request.toolName,
streamingId: request.streamingId
});
// Broadcast to the appropriate streaming session
if (request.streamingId && request.streamingId !== 'unknown') {
const event = {
type: 'permission_request',
data: request,
streamingId: request.streamingId,
timestamp: new Date().toISOString()
};
this.streamManager.broadcast(request.streamingId, event);
// Permission request notification removed
}
});
this.logger.debug('PermissionTracker integration setup complete');
}
async initializeOrReloadRouter(config) {
// If router is disabled, ensure it is stopped
if (!config.router?.enabled) {
if (this.routerService) {
this.logger.info('Router disabled in configuration, stopping router service');
await this.routerService.stop();
this.routerService = undefined;
this.processManager.setRouterService(undefined);
}
else {
this.logger.info('Router service is disabled');
}
return;
}
// If router is enabled
try {
// If there is an existing router, stop it first
if (this.routerService) {
this.logger.info('Reloading router service due to configuration change...');
await this.routerService.stop();
this.routerService = undefined;
}
else {
this.logger.debug('Router service is enabled, attempting to initialize...');
}
this.routerService = new ClaudeRouterService(config.router);
await this.routerService.initialize();
this.processManager.setRouterService(this.routerService);
this.logger.info('Router service initialized');
}
catch (error) {
this.logger.error('Router initialization failed, continuing without router', error);
}
}
}
//# sourceMappingURL=cui-server.js.map