UNPKG

vibetunnel

Version:

Terminal sharing server with web interface - supports macOS, Linux, and headless environments

1,099 lines (1,088 loc) 198 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __exportStar = (this && this.__exportStar) || function(m, exports) { for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.isShuttingDown = isShuttingDown; exports.setShuttingDown = setShuttingDown; exports.createApp = createApp; exports.startVibeTunnelServer = startVibeTunnelServer; // VibeTunnel server entry point const chalk_1 = __importDefault(require("chalk")); const compression_1 = __importDefault(require("compression")); const express_1 = __importDefault(require("express")); const fs = __importStar(require("fs")); const helmet_1 = __importDefault(require("helmet")); const http_1 = require("http"); const os = __importStar(require("os")); const path = __importStar(require("path")); const uuid_1 = require("uuid"); const ws_1 = require("ws"); const types_js_1 = require("../shared/types.js"); const api_socket_server_js_1 = require("./api-socket-server.js"); const auth_js_1 = require("./middleware/auth.js"); const index_js_1 = require("./pty/index.js"); const auth_js_2 = require("./routes/auth.js"); const config_js_1 = require("./routes/config.js"); const control_js_1 = require("./routes/control.js"); const events_js_1 = require("./routes/events.js"); const files_js_1 = require("./routes/files.js"); const filesystem_js_1 = require("./routes/filesystem.js"); const git_js_1 = require("./routes/git.js"); const logs_js_1 = require("./routes/logs.js"); const multiplexer_js_1 = require("./routes/multiplexer.js"); const push_js_1 = require("./routes/push.js"); const remotes_js_1 = require("./routes/remotes.js"); const repositories_js_1 = require("./routes/repositories.js"); const sessions_js_1 = require("./routes/sessions.js"); const test_notification_js_1 = require("./routes/test-notification.js"); const tmux_js_1 = require("./routes/tmux.js"); const websocket_input_js_1 = require("./routes/websocket-input.js"); const worktrees_js_1 = require("./routes/worktrees.js"); const activity_monitor_js_1 = require("./services/activity-monitor.js"); const auth_service_js_1 = require("./services/auth-service.js"); const buffer_aggregator_js_1 = require("./services/buffer-aggregator.js"); const config_service_js_1 = require("./services/config-service.js"); const control_dir_watcher_js_1 = require("./services/control-dir-watcher.js"); const hq_client_js_1 = require("./services/hq-client.js"); const mdns_service_js_1 = require("./services/mdns-service.js"); const push_notification_service_js_1 = require("./services/push-notification-service.js"); const remote_registry_js_1 = require("./services/remote-registry.js"); const session_monitor_js_1 = require("./services/session-monitor.js"); const stream_watcher_js_1 = require("./services/stream-watcher.js"); const tailscale_serve_service_js_1 = require("./services/tailscale-serve-service.js"); const terminal_manager_js_1 = require("./services/terminal-manager.js"); const logger_js_1 = require("./utils/logger.js"); const vapid_manager_js_1 = require("./utils/vapid-manager.js"); const version_js_1 = require("./version.js"); const control_unix_handler_js_1 = require("./websocket/control-unix-handler.js"); const logger = (0, logger_js_1.createLogger)('server'); // Global shutdown state management let shuttingDown = false; function isShuttingDown() { return shuttingDown; } function setShuttingDown(value) { shuttingDown = value; } // Show help message function showHelp() { console.log(` VibeTunnel Server - Terminal Multiplexer Usage: vibetunnel-server [options] Options: --help Show this help message --version Show version information --port <number> Server port (default: 4020 or PORT env var) --bind <address> Bind address (default: 0.0.0.0, all interfaces) --enable-ssh-keys Enable SSH key authentication UI and functionality --disallow-user-password Disable password auth, SSH keys only (auto-enables --enable-ssh-keys) --no-auth Disable authentication (auto-login as current user) --allow-local-bypass Allow localhost connections to bypass authentication --local-auth-token <token> Token for localhost authentication bypass --enable-tailscale-serve Enable Tailscale Serve integration (auto-manages proxy and auth) --debug Enable debug logging Push Notification Options: --push-enabled Enable push notifications (default: enabled) --push-disabled Disable push notifications --vapid-email <email> Contact email for VAPID (or PUSH_CONTACT_EMAIL env var) --generate-vapid-keys Generate new VAPID keys if none exist Network Discovery Options: --no-mdns Disable mDNS/Bonjour advertisement (enabled by default) HQ Mode Options: --hq Run as HQ (headquarters) server Remote Server Options: --hq-url <url> HQ server URL to register with --hq-username <user> Username for HQ authentication --hq-password <pass> Password for HQ authentication --name <name> Unique name for this remote server --allow-insecure-hq Allow HTTP URLs for HQ (default: HTTPS only) --no-hq-auth Disable HQ authentication (for testing only) Environment Variables: PORT Default port if --port not specified VIBETUNNEL_USERNAME Default username if --username not specified VIBETUNNEL_PASSWORD Default password if --password not specified VIBETUNNEL_CONTROL_DIR Control directory for session data PUSH_CONTACT_EMAIL Contact email for VAPID configuration Examples: # Run a simple server with authentication vibetunnel-server --username admin --password secret # Run as HQ server vibetunnel-server --hq --username hq-admin --password hq-secret # Run as remote server registering with HQ vibetunnel-server --username local --password local123 \\ --hq-url https://hq.example.com \\ --hq-username hq-admin --hq-password hq-secret \\ --name remote-1 `); } // Parse command line arguments function parseArgs() { const args = process.argv.slice(2); const config = { port: null, bind: null, enableSSHKeys: false, disallowUserPassword: false, noAuth: false, isHQMode: false, hqUrl: null, hqUsername: null, hqPassword: null, remoteName: null, allowInsecureHQ: false, showHelp: false, showVersion: false, debug: false, // Push notification configuration pushEnabled: true, // Enable by default with auto-generation vapidEmail: null, generateVapidKeys: true, // Generate keys automatically bellNotificationsEnabled: true, // Enable bell notifications by default // Local bypass configuration allowLocalBypass: false, localAuthToken: null, // Tailscale Serve integration (manages auth and proxy) enableTailscaleServe: false, // HQ auth bypass for testing noHqAuth: false, // mDNS advertisement enableMDNS: true, // Enable mDNS by default }; // Check for help flag first if (args.includes('--help') || args.includes('-h')) { config.showHelp = true; return config; } // Check for version flag if (args.includes('--version') || args.includes('-v')) { config.showVersion = true; return config; } // Check for command line arguments for (let i = 0; i < args.length; i++) { if (args[i] === '--port' && i + 1 < args.length) { config.port = Number.parseInt(args[i + 1], 10); i++; // Skip the port value in next iteration } else if (args[i] === '--bind' && i + 1 < args.length) { config.bind = args[i + 1]; i++; // Skip the bind value in next iteration } else if (args[i] === '--enable-ssh-keys') { config.enableSSHKeys = true; } else if (args[i] === '--disallow-user-password') { config.disallowUserPassword = true; config.enableSSHKeys = true; // Auto-enable SSH keys } else if (args[i] === '--no-auth') { config.noAuth = true; } else if (args[i] === '--hq') { config.isHQMode = true; } else if (args[i] === '--hq-url' && i + 1 < args.length) { config.hqUrl = args[i + 1]; i++; // Skip the URL value in next iteration } else if (args[i] === '--hq-username' && i + 1 < args.length) { config.hqUsername = args[i + 1]; i++; // Skip the username value in next iteration } else if (args[i] === '--hq-password' && i + 1 < args.length) { config.hqPassword = args[i + 1]; i++; // Skip the password value in next iteration } else if (args[i] === '--name' && i + 1 < args.length) { config.remoteName = args[i + 1]; i++; // Skip the name value in next iteration } else if (args[i] === '--allow-insecure-hq') { config.allowInsecureHQ = true; } else if (args[i] === '--debug') { config.debug = true; } else if (args[i] === '--push-enabled') { config.pushEnabled = true; } else if (args[i] === '--push-disabled') { config.pushEnabled = false; } else if (args[i] === '--vapid-email' && i + 1 < args.length) { config.vapidEmail = args[i + 1]; i++; // Skip the email value in next iteration } else if (args[i] === '--generate-vapid-keys') { config.generateVapidKeys = true; } else if (args[i] === '--allow-local-bypass') { config.allowLocalBypass = true; } else if (args[i] === '--local-auth-token' && i + 1 < args.length) { config.localAuthToken = args[i + 1]; i++; // Skip the token value in next iteration } else if (args[i] === '--enable-tailscale-serve') { config.enableTailscaleServe = true; } else if (args[i] === '--no-hq-auth') { config.noHqAuth = true; } else if (args[i] === '--no-mdns') { config.enableMDNS = false; } else if (args[i].startsWith('--')) { // Unknown argument logger.error(`Unknown argument: ${args[i]}`); logger.error('Use --help to see available options'); process.exit(1); } } // Check environment variables for push notifications if (!config.vapidEmail && process.env.PUSH_CONTACT_EMAIL) { config.vapidEmail = process.env.PUSH_CONTACT_EMAIL; } return config; } // Validate configuration function validateConfig(config) { // Validate auth configuration if (config.noAuth && (config.enableSSHKeys || config.disallowUserPassword)) { logger.warn('--no-auth overrides all other authentication settings (authentication is disabled)'); } if (config.disallowUserPassword && !config.enableSSHKeys) { logger.warn('--disallow-user-password requires SSH keys, auto-enabling --enable-ssh-keys'); config.enableSSHKeys = true; } // Validate HQ registration configuration if (config.hqUrl && (!config.hqUsername || !config.hqPassword) && !config.noHqAuth) { logger.error('HQ username and password required when --hq-url is specified'); logger.error('Use --hq-username and --hq-password with --hq-url'); logger.error('Or use --no-hq-auth for testing without authentication'); process.exit(1); } // Validate remote name is provided when registering with HQ if (config.hqUrl && !config.remoteName) { logger.error('Remote name required when --hq-url is specified'); logger.error('Use --name to specify a unique name for this remote server'); process.exit(1); } // Validate HQ URL is HTTPS unless explicitly allowed if (config.hqUrl && !config.hqUrl.startsWith('https://') && !config.allowInsecureHQ) { logger.error('HQ URL must use HTTPS protocol'); logger.error('Use --allow-insecure-hq to allow HTTP for testing'); process.exit(1); } // Validate HQ registration configuration if ((config.hqUrl || config.hqUsername || config.hqPassword) && (!config.hqUrl || !config.hqUsername || !config.hqPassword) && !config.noHqAuth) { logger.error('All HQ parameters required: --hq-url, --hq-username, --hq-password'); logger.error('Or use --no-hq-auth for testing without authentication'); process.exit(1); } // Validate Tailscale configuration if (config.enableTailscaleServe && config.bind === '0.0.0.0') { logger.error('Security Error: Cannot bind to 0.0.0.0 when using Tailscale Serve'); logger.error('Tailscale Serve requires binding to localhost (127.0.0.1)'); logger.error('Use --bind 127.0.0.1 or disable Tailscale Serve'); process.exit(1); } // Can't be both HQ mode and register with HQ if (config.isHQMode && config.hqUrl) { logger.error('Cannot use --hq and --hq-url together'); logger.error('Use --hq to run as HQ server, or --hq-url to register with an HQ'); process.exit(1); } // Warn about no-hq-auth if (config.noHqAuth && config.hqUrl) { logger.warn('--no-hq-auth is enabled: Remote servers can register without authentication'); logger.warn('This should only be used for testing!'); } } // Track if app has been created let appCreated = false; async function createApp() { // Prevent multiple app instances if (appCreated) { logger.error('App already created, preventing duplicate instance'); throw new Error('Duplicate app creation detected'); } appCreated = true; const config = parseArgs(); // Check if help was requested if (config.showHelp) { showHelp(); process.exit(0); } // Check if version was requested if (config.showVersion) { const versionInfo = (0, version_js_1.getVersionInfo)(); console.log(`VibeTunnel Server v${versionInfo.version}`); console.log(`Built: ${versionInfo.buildDate}`); console.log(`Platform: ${versionInfo.platform}/${versionInfo.arch}`); console.log(`Node: ${versionInfo.nodeVersion}`); process.exit(0); } // Print version banner on startup (0, version_js_1.printVersionBanner)(); validateConfig(config); logger.log('Initializing VibeTunnel server components'); const app = (0, express_1.default)(); const server = (0, http_1.createServer)(app); const wss = new ws_1.WebSocketServer({ noServer: true, perMessageDeflate: true }); // Add security headers with Helmet app.use((0, helmet_1.default)({ contentSecurityPolicy: false, // We handle CSP ourselves for the web terminal crossOriginEmbedderPolicy: false, // Allow embedding in iframes for integrations })); logger.debug('Configured security headers with helmet'); // Add compression middleware with Brotli support // Skip compression for SSE streams (asciicast and events) app.use((0, compression_1.default)({ filter: (req, res) => { // Skip compression for Server-Sent Events if (req.path.match(/\/api\/sessions\/[^/]+\/stream$/) || req.path === '/api/events') { return false; } // Use default filter for other requests return compression_1.default.filter(req, res); }, // Enable Brotli compression with highest priority level: 6, // Balanced compression level })); logger.debug('Configured compression middleware (with SSE exclusion)'); // Add JSON body parser middleware with size limit app.use(express_1.default.json({ limit: '10mb' })); logger.debug('Configured express middleware'); // Control directory for session data const CONTROL_DIR = process.env.VIBETUNNEL_CONTROL_DIR || path.join(os.homedir(), '.vibetunnel/control'); // Ensure control directory exists if (!fs.existsSync(CONTROL_DIR)) { fs.mkdirSync(CONTROL_DIR, { recursive: true }); logger.log(chalk_1.default.green(`Created control directory: ${CONTROL_DIR}`)); } else { logger.debug(`Using existing control directory: ${CONTROL_DIR}`); } // Initialize PTY manager with fallback support await index_js_1.PtyManager.initialize(); const ptyManager = new index_js_1.PtyManager(CONTROL_DIR); logger.debug('Initialized PTY manager'); // Clean up sessions from old VibeTunnel versions const sessionManager = ptyManager.getSessionManager(); const cleanupResult = sessionManager.cleanupOldVersionSessions(); if (cleanupResult.versionChanged) { logger.log(chalk_1.default.yellow(`Version change detected - cleaned up ${cleanupResult.cleanedCount} sessions from previous version`)); } else if (cleanupResult.cleanedCount > 0) { logger.log(chalk_1.default.yellow(`Cleaned up ${cleanupResult.cleanedCount} legacy sessions without version information`)); } // Initialize Terminal Manager for server-side terminal state const terminalManager = new terminal_manager_js_1.TerminalManager(CONTROL_DIR); logger.debug('Initialized terminal manager'); // Initialize stream watcher for file-based streaming const streamWatcher = new stream_watcher_js_1.StreamWatcher(sessionManager); logger.debug('Initialized stream watcher'); // Initialize session monitor with PTY manager const sessionMonitor = new session_monitor_js_1.SessionMonitor(ptyManager); await sessionMonitor.initialize(); // Set the session monitor on PTY manager for data tracking ptyManager.setSessionMonitor(sessionMonitor); logger.debug('Initialized session monitor'); // Initialize activity monitor const activityMonitor = new activity_monitor_js_1.ActivityMonitor(CONTROL_DIR); logger.debug('Initialized activity monitor'); // Initialize configuration service const configService = new config_service_js_1.ConfigService(); configService.startWatching(); logger.debug('Initialized configuration service'); // Initialize push notification services let vapidManager = null; let pushNotificationService = null; if (config.pushEnabled) { try { logger.log('Initializing push notification services'); // Initialize VAPID manager with auto-generation vapidManager = new vapid_manager_js_1.VapidManager(); await vapidManager.initialize({ contactEmail: config.vapidEmail || 'noreply@vibetunnel.local', generateIfMissing: true, // Auto-generate keys if none exist }); logger.log('VAPID keys initialized successfully'); // Initialize push notification service pushNotificationService = new push_notification_service_js_1.PushNotificationService(vapidManager); await pushNotificationService.initialize(); logger.log(chalk_1.default.green('Push notification services initialized')); } catch (error) { logger.error('Failed to initialize push notification services:', error); logger.warn('Continuing without push notifications'); vapidManager = null; pushNotificationService = null; } } else { logger.debug('Push notifications disabled'); } // Connect SessionMonitor to push notification service if (sessionMonitor && pushNotificationService) { logger.info('Connecting SessionMonitor to push notification service'); // Listen for session monitor notifications and send push notifications sessionMonitor.on('notification', async (event) => { try { // Map event types to push notification data let pushPayload = null; switch (event.type) { case types_js_1.ServerEventType.SessionStart: pushPayload = { type: 'session-start', title: '🚀 Session Started', body: event.sessionName || 'Terminal Session', }; break; case types_js_1.ServerEventType.SessionExit: pushPayload = { type: 'session-exit', title: '🏁 Session Ended', body: event.sessionName || 'Terminal Session', data: { exitCode: event.exitCode }, }; break; case types_js_1.ServerEventType.CommandFinished: pushPayload = { type: 'command-finished', title: '✅ Your Turn', body: event.command || 'Command completed', data: { duration: event.duration }, }; break; case types_js_1.ServerEventType.CommandError: pushPayload = { type: 'command-error', title: '❌ Command Failed', body: event.command || 'Command failed', data: { exitCode: event.exitCode }, }; break; case types_js_1.ServerEventType.Bell: pushPayload = { type: 'bell', title: '🔔 Terminal Bell', body: event.sessionName || 'Terminal', }; break; case types_js_1.ServerEventType.ClaudeTurn: pushPayload = { type: 'claude-turn', title: '💬 Your Turn', body: event.message || 'Claude has finished responding', }; break; case types_js_1.ServerEventType.TestNotification: // Test notifications are already handled by the test endpoint return; default: return; // Skip unknown event types } if (pushPayload) { // Send push notification const result = await pushNotificationService.sendNotification({ ...pushPayload, icon: '/apple-touch-icon.png', badge: '/favicon-32.png', tag: `vibetunnel-${pushPayload.type}`, requireInteraction: pushPayload.type === 'command-error', actions: [ { action: 'view-session', title: 'View Session', }, { action: 'dismiss', title: 'Dismiss', }, ], data: { ...pushPayload.data, type: pushPayload.type, sessionId: event.sessionId, timestamp: event.timestamp, }, }); logger.debug(`Push notification sent for ${event.type}: ${result.sent} successful, ${result.failed} failed`); } } catch (error) { logger.error('Failed to send push notification for SessionMonitor event:', error); } }); } // Initialize HQ components let remoteRegistry = null; let hqClient = null; let controlDirWatcher = null; let bufferAggregator = null; let remoteBearerToken = null; if (config.isHQMode) { remoteRegistry = new remote_registry_js_1.RemoteRegistry(); logger.log(chalk_1.default.green('Running in HQ mode')); logger.debug('Initialized remote registry for HQ mode'); } else if (config.hqUrl && config.remoteName && (config.noHqAuth || (config.hqUsername && config.hqPassword))) { // Generate bearer token for this remote server remoteBearerToken = (0, uuid_1.v4)(); logger.debug(`Generated bearer token for remote server: ${config.remoteName}`); } // Initialize authentication service const authService = new auth_service_js_1.AuthService(); logger.debug('Initialized authentication service'); // Initialize buffer aggregator bufferAggregator = new buffer_aggregator_js_1.BufferAggregator({ terminalManager, remoteRegistry, isHQMode: config.isHQMode, }); logger.debug('Initialized buffer aggregator'); // Initialize WebSocket input handler const websocketInputHandler = new websocket_input_js_1.WebSocketInputHandler({ ptyManager, terminalManager, activityMonitor, remoteRegistry, authService, isHQMode: config.isHQMode, }); logger.debug('Initialized WebSocket input handler'); // Set up authentication const authMiddleware = (0, auth_js_1.createAuthMiddleware)({ enableSSHKeys: config.enableSSHKeys, disallowUserPassword: config.disallowUserPassword, noAuth: config.noAuth, isHQMode: config.isHQMode, bearerToken: remoteBearerToken || undefined, // Token that HQ must use to auth with us authService, // Add enhanced auth service for JWT tokens allowLocalBypass: config.allowLocalBypass, localAuthToken: config.localAuthToken || undefined, allowTailscaleAuth: config.enableTailscaleServe, }); // Serve static files with .html extension handling and caching headers // In production/bundled mode, use the package directory; in development, use cwd const getPublicPath = () => { // First check if BUILD_PUBLIC_PATH is set (used by Mac app bundle) if (process.env.BUILD_PUBLIC_PATH) { logger.info(`Using BUILD_PUBLIC_PATH: ${process.env.BUILD_PUBLIC_PATH}`); return process.env.BUILD_PUBLIC_PATH; } // More precise npm package detection: // 1. Check if we're explicitly in an npm package structure // 2. The file should be in node_modules/vibetunnel/lib/ // 3. Or check for our specific package markers const isNpmPackage = (() => { // Most reliable: check if we're in node_modules/vibetunnel structure if (__filename.includes(path.join('node_modules', 'vibetunnel', 'lib'))) { return true; } // Check for Windows path variant if (__filename.includes('node_modules\\vibetunnel\\lib')) { return true; } // Secondary check: if we're in a lib directory, verify it's actually an npm package // by checking for the existence of package.json in the parent directory if (path.basename(__dirname) === 'lib') { const parentDir = path.dirname(__dirname); const packageJsonPath = path.join(parentDir, 'package.json'); try { const packageJson = require(packageJsonPath); // Verify this is actually our package return packageJson.name === 'vibetunnel'; } catch { // Not a valid npm package structure return false; } } return false; })(); if (process.env.VIBETUNNEL_BUNDLED === 'true' || process.env.BUILD_DATE || isNpmPackage) { // In bundled/production/npm mode, find package root // When bundled, __dirname is /path/to/package/dist, so go up one level // When globally installed, we need to find the package root let packageRoot = __dirname; // If we're in the dist directory, go up one level if (path.basename(packageRoot) === 'dist') { packageRoot = path.dirname(packageRoot); } // For npm package context, if we're in lib directory, go up one level if (path.basename(packageRoot) === 'lib') { packageRoot = path.dirname(packageRoot); } // Look for package.json to confirm we're in the right place const publicPath = path.join(packageRoot, 'public'); const indexPath = path.join(publicPath, 'index.html'); // If index.html exists, we found the right path if (require('fs').existsSync(indexPath)) { return publicPath; } // Fallback: try going up from the bundled CLI location // The bundled CLI might be in node_modules/vibetunnel/dist/ return path.join(__dirname, '..', 'public'); } else { // In development mode, use current working directory return path.join(process.cwd(), 'public'); } }; const publicPath = getPublicPath(); const isDevelopment = !process.env.BUILD_DATE || process.env.NODE_ENV === 'development'; app.use(express_1.default.static(publicPath, { extensions: ['html'], // This allows /logs to resolve to /logs.html maxAge: isDevelopment ? 0 : '1d', // No cache in dev, 1 day in production etag: !isDevelopment, // Disable ETag in development lastModified: !isDevelopment, // Disable Last-Modified in development setHeaders: (res, filePath) => { if (isDevelopment) { // Disable all caching in development res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } else { // Production caching rules // Set longer cache for immutable assets if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } // Shorter cache for HTML files else if (filePath.endsWith('.html')) { res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour } } }, })); logger.debug(`Serving static files from: ${publicPath} ${isDevelopment ? 'with caching disabled (dev mode)' : 'with caching headers'}`); // Health check endpoint (no auth required) app.get('/api/health', (_req, res) => { const versionInfo = (0, version_js_1.getVersionInfo)(); res.json({ status: 'ok', timestamp: new Date().toISOString(), mode: config.isHQMode ? 'hq' : 'remote', version: versionInfo.version, buildDate: versionInfo.buildDate, uptime: versionInfo.uptime, pid: versionInfo.pid, }); }); // Connect session exit notifications if push notifications are enabled if (pushNotificationService) { ptyManager.on('sessionExited', (sessionId) => { // Load session info to get details const sessionInfo = sessionManager.loadSessionInfo(sessionId); const exitCode = sessionInfo?.exitCode ?? 0; const sessionName = sessionInfo?.name || `Session ${sessionId}`; // Determine notification type based on exit code const notificationType = exitCode === 0 ? 'session-exit' : 'session-error'; const title = exitCode === 0 ? 'Session Ended' : 'Session Ended with Errors'; const body = exitCode === 0 ? `${sessionName} has finished.` : `${sessionName} exited with code ${exitCode}.`; pushNotificationService .sendNotification({ type: notificationType, title, body, icon: '/apple-touch-icon.png', badge: '/favicon-32.png', tag: `vibetunnel-${notificationType}-${sessionId}`, requireInteraction: false, data: { type: notificationType, sessionId, sessionName, exitCode, timestamp: new Date().toISOString(), }, actions: [ { action: 'view-logs', title: 'View Logs' }, { action: 'dismiss', title: 'Dismiss' }, ], }) .catch((error) => { logger.error('Failed to send session exit notification:', error); }); }); logger.debug('Connected session exit notifications to PTY manager'); // Connect command finished notifications ptyManager.on('commandFinished', ({ sessionId, command, exitCode, duration, timestamp }) => { const isClaudeCommand = command.toLowerCase().includes('claude'); // Enhanced logging for Claude commands if (isClaudeCommand) { logger.log(chalk_1.default.magenta(`📬 Server received Claude commandFinished event: sessionId=${sessionId}, command="${command}", exitCode=${exitCode}, duration=${duration}ms`)); } else { logger.debug(`Server received commandFinished event for session ${sessionId}: "${command}"`); } // Determine notification type based on exit code const notificationType = exitCode === 0 ? 'command-finished' : 'command-error'; const title = exitCode === 0 ? 'Command Completed' : 'Command Failed'; const body = exitCode === 0 ? `${command} completed successfully` : `${command} failed with exit code ${exitCode}`; // Format duration for display const durationStr = duration > 60000 ? `${Math.round(duration / 60000)}m ${Math.round((duration % 60000) / 1000)}s` : `${Math.round(duration / 1000)}s`; logger.debug(`Sending push notification: type=${notificationType}, title="${title}", body="${body} (${durationStr})"`); pushNotificationService .sendNotification({ type: notificationType, title, body: `${body} (${durationStr})`, icon: '/apple-touch-icon.png', badge: '/favicon-32.png', tag: `vibetunnel-command-${sessionId}-${Date.now()}`, requireInteraction: false, data: { type: notificationType, sessionId, command, exitCode, duration, timestamp, }, actions: [ { action: 'view-session', title: 'View Session' }, { action: 'dismiss', title: 'Dismiss' }, ], }) .catch((error) => { logger.error('Failed to send command finished notification:', error); }); }); logger.debug('Connected command finished notifications to PTY manager'); // Connect Claude turn notifications ptyManager.on('claudeTurn', (sessionId, sessionName) => { logger.info(`🔔 NOTIFICATION DEBUG: Sending push notification for Claude turn - sessionId: ${sessionId}`); pushNotificationService .sendNotification({ type: 'claude-turn', title: 'Claude Ready', body: `${sessionName} is waiting for your input.`, icon: '/apple-touch-icon.png', badge: '/favicon-32.png', tag: `vibetunnel-claude-turn-${sessionId}`, requireInteraction: true, data: { type: 'claude-turn', sessionId, sessionName, timestamp: new Date().toISOString(), }, actions: [ { action: 'view-session', title: 'View Session' }, { action: 'dismiss', title: 'Dismiss' }, ], }) .catch((error) => { logger.error('Failed to send Claude turn notification:', error); }); }); logger.debug('Connected Claude turn notifications to PTY manager'); } // Apply auth middleware to all API routes (including auth routes for Tailscale header detection) app.use('/api', authMiddleware); logger.debug('Applied authentication middleware to /api routes'); // Mount authentication routes (auth middleware will skip these but still check Tailscale headers) app.use('/api/auth', (0, auth_js_2.createAuthRoutes)({ authService, enableSSHKeys: config.enableSSHKeys, disallowUserPassword: config.disallowUserPassword, noAuth: config.noAuth, })); logger.debug('Mounted authentication routes'); // Mount routes app.use('/api', (0, sessions_js_1.createSessionRoutes)({ ptyManager, terminalManager, streamWatcher, remoteRegistry, isHQMode: config.isHQMode, activityMonitor, })); logger.debug('Mounted session routes'); app.use('/api', (0, remotes_js_1.createRemoteRoutes)({ remoteRegistry, isHQMode: config.isHQMode, })); logger.debug('Mounted remote routes'); // Mount filesystem routes app.use('/api', (0, filesystem_js_1.createFilesystemRoutes)()); logger.debug('Mounted filesystem routes'); // Mount log routes app.use('/api', (0, logs_js_1.createLogRoutes)()); logger.debug('Mounted log routes'); // Mount file routes app.use('/api', (0, files_js_1.createFileRoutes)()); logger.debug('Mounted file routes'); // Mount repository routes app.use('/api', (0, repositories_js_1.createRepositoryRoutes)()); logger.debug('Mounted repository routes'); // Mount config routes app.use('/api', (0, config_js_1.createConfigRoutes)({ configService, })); logger.debug('Mounted config routes'); // Mount Git routes app.use('/api', (0, git_js_1.createGitRoutes)()); logger.debug('Mounted Git routes'); // Mount worktree routes app.use('/api', (0, worktrees_js_1.createWorktreeRoutes)()); logger.debug('Mounted worktree routes'); // Mount control routes app.use('/api', (0, control_js_1.createControlRoutes)()); logger.debug('Mounted control routes'); // Mount tmux routes app.use('/api/tmux', (0, tmux_js_1.createTmuxRoutes)({ ptyManager })); logger.debug('Mounted tmux routes'); // Mount multiplexer routes (unified tmux/zellij interface) app.use('/api/multiplexer', (0, multiplexer_js_1.createMultiplexerRoutes)({ ptyManager })); logger.debug('Mounted multiplexer routes'); // Mount push notification routes - always mount even if VAPID is not initialized // This ensures proper error responses instead of 404s app.use('/api', (0, push_js_1.createPushRoutes)({ vapidManager: vapidManager || new vapid_manager_js_1.VapidManager(), // Pass a dummy instance if null pushNotificationService, sessionMonitor, })); logger.debug('Mounted push notification routes'); // Mount events router for SSE streaming app.use('/api', (0, events_js_1.createEventsRouter)(sessionMonitor)); logger.debug('Mounted events routes'); // Mount test notification router app.use('/api', (0, test_notification_js_1.createTestNotificationRouter)({ sessionMonitor, pushNotificationService })); logger.debug('Mounted test notification routes'); // Initialize control socket try { await control_unix_handler_js_1.controlUnixHandler.start(); logger.log(chalk_1.default.green('Control UNIX socket: READY')); } catch (error) { logger.error('Failed to initialize control socket:', error); logger.warn('Mac control features will not be available.'); // Depending on the desired behavior, you might want to exit here // For now, we'll let the server continue without these features. } // Initialize API socket for CLI commands try { await api_socket_server_js_1.apiSocketServer.start(); logger.log(chalk_1.default.green('API socket server: READY')); } catch (error) { logger.error('Failed to initialize API socket server:', error); logger.warn('vt commands will not work via socket.'); } // Handle WebSocket upgrade with authentication server.on('upgrade', async (request, socket, head) => { // Parse the URL to extract path and query parameters const parsedUrl = new URL(request.url || '', `http://${request.headers.host || 'localhost'}`); // Handle WebSocket paths if (parsedUrl.pathname !== '/buffers' && parsedUrl.pathname !== '/ws/input') { socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); socket.destroy(); return; } // Check authentication and capture user info const authResult = await new Promise((resolve) => { // Track if promise has been resolved to prevent multiple resolutions let resolved = false; const safeResolve = (value) => { if (!resolved) { resolved = true; resolve(value); } }; // Convert URLSearchParams to plain object for query parameters const query = {}; parsedUrl.searchParams.forEach((value, key) => { query[key] = value; }); // Create a mock Express request/response to use auth middleware const req = { ...request, url: request.url, path: parsedUrl.pathname, userId: undefined, authMethod: undefined, query, // Include parsed query parameters for token-based auth headers: request.headers, ip: request.socket.remoteAddress || '', socket: request.socket, hostname: request.headers.host?.split(':')[0] || 'localhost', // Add minimal Express-like methods needed by auth middleware get: (header) => request.headers[header.toLowerCase()], header: (header) => request.headers[header.toLowerCase()], accepts: () => false, acceptsCharsets: () => false, acceptsEncodings: () => false, acceptsLanguages: () => false, }; let authFailed = false; const res = { status: (code) => { // Only consider it a failure if it's an error status code if (code >= 400) { authFailed = true; safeResolve({ authenticated: false }); } return { json: () => { }, send: () => { }, end: () => { }, }; }, setHeader: () => { }, send: () => { }, json: () => { }, end: () => { }, }; const next = (error) => { // Authentication succeeds if next() is called without error and no auth failure was recorded const authenticated = !error && !authFailed; safeResolve({ authenticated, userId: req.userId, authMethod: req.authMethod, }); }; // Add a timeout to prevent indefinite hanging const timeoutId = setTimeout(() => { logger.error('WebSocket auth timeout - auth middleware did not complete in time'); safeResolve({ authenticated: false }); }, 5000); // 5 second timeout // Call authMiddleware and handle potential async errors Promise.resolve(authMiddleware(req, res, next)) .then(() => { clearTimeout(timeoutId); }) .catch((error) => { clearTimeout(timeoutId); logger.error('Auth middleware error:', error); safeResolve({ authenticated: false }); }); }); if (!authResult.authenticated) { logger.debug('WebSocket connection rejected: unauthorized'); socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } // Handle the upgrade wss.handleUpgrade(request, socket, head, (ws) => { // Add path and auth information to the request for routing const wsRequest = request; wsRequest.pathname = parsedUrl.pathname; wsRequest.searchParams = parsedUrl.searchParams; wsRequest.userId = authResult.userId; wsRequest.authMethod = authResult.authMethod; wss.emit('connection', ws, wsRequest); }); }); // WebSocket connection router wss.on('connection', (ws, req) => { const wsReq = req; const pathname = wsReq.pathname; const searchParams = wsReq.searchParams; logger.log(`🔌 WebSocket connection to path: ${pathname}`); logger.log(`👤 User ID: ${wsReq.userId || 'unknown'}`); logger.log(`🔐 Auth method: ${wsReq.authMethod || 'unknown'}`); if (pathname === '/buffers') { logger.log('📊 Handling buffer WebSocket connection'); // Handle buffer updates WebSocket if (bufferAggregator) { bufferAggregator.handleClientConnection(ws); } else { logger.error('BufferAggregator not initialized for WebSocket connection'); ws.close(); } } else if (pathname === '/ws/input') { logger.log('⌨️ Handling input WebSocket connection'); // Handle input WebSocket const sessionId = searchParams?.get('sessionId'); if (!sessionId) { logger.error('WebSocket input connection missing sessionId parameter'); ws.close(); return; } // Extract user ID from the authenticated request const userId = wsReq.userId || 'unknown'; websocketInputHandler.handleConnection(ws, sessionId, userId); } else { logger.error(`❌ Unknown WebSocket path: ${pathname}`); ws.close(); } }); // Serve index.html for client-side routes (but not API routes) app.get('/', (_req, res) => { res.sendFile(path.join(publicPath, 'index.html')); }); // Handle /session/:id routes by serving the same index.html app.get('/session/:id', (_req, res) => { res.sendFile(path.join(publicPath, 'index.html')); }); // Handle /worktrees route by serving the same index.html app.get('/worktrees', (_req, res) => { res.sendFile(path.join(publicPath, 'index.html')); }); // Handle /file-browser route by serving the same index.html app.get('/file-browser', (_req, res) => { res.sendFile(path.join(publicPath, 'index.html')); }); // 404 handler for all other routes app.use((req, res) => { if (req.path.startsWith('/api/')) { res.status(404).json({ error: 'API endpoint not found' }); }