#!/usr/bin/env bun // Runtime guard — Bun only, reject Node.js and Deno if (typeof globalThis.Bun === 'undefined') { console.error('\x1b[31mError: Clopen requires Bun runtime.\x1b[0m'); console.error('Node.js and Deno are not supported.'); console.error('Install Bun: https://bun.sh'); process.exit(1); } // Note: the node:v8 isBuildingSnapshot shim (backend/utils/bun-compat.ts) is // applied via Bun `preload` (bunfig.toml + --preload in bin/clopen.ts), NOT a // plain import here — Bun does not guarantee side-effect import order ahead of // the transitive mongodb/bson CJS import, so an import would run too late. // MUST be first import — cleans process.env before any other module reads it. import { SERVER_ENV } from './utils/env'; import { Elysia } from 'elysia'; import { corsMiddleware } from './middleware/cors'; import { errorHandlerMiddleware } from './middleware/error-handler'; import { loggerMiddleware } from './middleware/logger'; import { securityMiddleware } from './middleware/security'; // Database initialization import { initializeDatabase, closeDatabase } from './database'; import { bootstrapAfterDbInit } from './bootstrap'; import { startEngineConfigWatcher } from './engine/config-revision'; import { startEngineHotReload } from './engine/hot-reload'; import { disposeAllEngines } from './engine'; import { connectionManager } from './db-client/connection-manager'; import { sshClientPool } from './ssh/client-pool'; import { sshForwardManager } from './ssh/forwards'; import { refreshProcessPath } from './utils/path-enrich'; import { installProcessWarningFilter } from './utils/process-warnings'; import { debug } from '$shared/utils/logger'; import { networkInterfaces } from 'os'; import { resolve } from 'node:path'; import { statSync } from 'node:fs'; // Import WebSocket router import { wsRouter } from './ws'; // HTTP upload route — bypasses the Vite WS proxy, which corrupts sustained // binary transfers with `write EPIPE`. See backend/http/files-upload.ts. import { filesUploadRoute } from './http/files-upload'; import { filesDownloadRoute } from './http/files-download'; import { filesSharedRoute } from './http/files-shared'; // HTTP routes for per-user notification sounds (upload / serve / delete). import { audioRoute } from './http/audio'; // HTTP routes for Web Push subscriptions + test (mobile background notifications). import { pushRoute } from './http/push'; // HTTP routes for SFTP transfer — same reason as the file upload route above. import { sshSftpRoute } from './http/ssh-sftp'; import { notesImagesRoute } from './http/notes-images'; import { integrationHooksRoute } from './http/integration-hooks'; // Import browser preview manager for graceful shutdown import { browserPreviewServiceManager } from './preview'; // MCP remote server for Open Code custom tools import { handleMcpRequest, handleExternalMcpRequest, closeMcpServer, completeAuthorization } from './mcp'; // Auth middleware import { checkRouteAccess, PUBLIC_ROUTES } from './auth/permissions'; import { getAuthMode } from './settings/system-settings'; import { authQueries } from './database/queries'; import { authRateLimiter } from './auth'; import { sessionCleanupScheduler } from './auth/session-cleanup'; import { portMonitor } from './ports/monitor'; import { containerMonitor } from './containers/monitor'; import { stopAllLogStreams as stopAllContainerLogStreams } from './containers/logs'; import { stopAllBuildLogStreams } from './deployments/log-streams'; import { uploadTempCleanup } from './http/upload-temp-cleanup'; import { ws as wsServer } from './utils/ws'; import { messageRateLimiter } from './ws/message-rate-limiter'; import { flushEpisodicIngest, stopExtractionRunner } from './memory/extract'; import { stopMemoryMaintenance } from './memory/maintenance'; /** How often (ms) the auth gate re-confirms a connection's session against the DB. */ const SESSION_REVALIDATE_MS = 15_000; // Register auth gate on WebSocket router — blocks unauthenticated/unauthorized access wsRouter.setAuthMiddleware(async (conn, action) => { const isAuth = wsServer.isAuthenticated(conn); const role = wsServer.getRole(conn); const result = checkRouteAccess(action, isAuth, role); if (!result.allowed) return result; // Defense-in-depth: an authenticated connection trusts the in-memory auth set // at login. If its session was revoked/expired/deleted afterwards (sign-out // from another device, admin removal, expiry), the live socket would otherwise // keep full access until it reconnects. Periodically re-confirm the session // still exists in the DB and kick the connection the moment it doesn't. if (isAuth && getAuthMode() === 'required' && !PUBLIC_ROUTES.has(action)) { if (wsServer.dueForSessionCheck(conn, SESSION_REVALIDATE_MS)) { const hash = wsServer.getSessionTokenHash(conn); const session = hash ? authQueries.getSessionByTokenHash(hash) : null; const valid = !!session && new Date(session.expires_at) >= new Date(); if (!valid) { wsServer.invalidateConnection(conn, 'Your session has ended'); return { allowed: false, error: 'Session ended' }; } } } return result; }); // Register message rate limiter on WebSocket router — prevents DoS via message spam wsRouter.setRateLimiter((conn, action, isRequest) => { return messageRateLimiter.checkRateLimit(conn, action, isRequest); }); /** * Clopen - Elysia Backend Server * * Development: Elysia runs on port 9161, Vite dev server proxies /api and /ws from port 9151 * Production: Elysia runs on port 9141, serves static files from dist/ + API + WebSocket */ function getLocalIps(): string[] { const ips: string[] = []; for (const ifaces of Object.values(networkInterfaces())) { for (const iface of ifaces ?? []) { if (iface.family === 'IPv4' && !iface.internal) ips.push(iface.address); } } return ips; } const isDevelopment = SERVER_ENV.isDevelopment; const PORT = SERVER_ENV.PORT; const HOST = SERVER_ENV.HOST; // Create Elysia app const app = new Elysia() // Apply middleware .use(corsMiddleware) .use(securityMiddleware) .use(errorHandlerMiddleware) .use(loggerMiddleware) // API routes .get('/api/health', () => ({ status: 'ok', timestamp: new Date().toISOString(), environment: SERVER_ENV.NODE_ENV })) // MCP remote server endpoint for Open Code custom tools // Handles GET (SSE stream), POST (JSON-RPC), DELETE (session close) .all('/mcp', async ({ request, server }) => { // MCP tool calls can run far longer than Bun's default 10s idle timeout. // While a tool executes, the streaming response sends no bytes, so Bun // would close the idle connection and the call surfaces as MCP error // -32001 ("Request timed out"). Disable the idle timeout for this // long-lived endpoint only — every other route keeps the safe default. server?.timeout(request, 0); return handleMcpRequest(request); }) // Per-server proxy for user-installed (external) MCP servers. Clopen connects // to the upstream third-party server and re-exposes its sanitized tools here, // so engines never connect to it directly — see backend/mcp/external/proxy.ts. // Same long-lived semantics as `/mcp`: disable Bun's idle timeout. .all('/mcp/ext/:slug', async ({ request, params, server }) => { server?.timeout(request, 0); return handleExternalMcpRequest(request, params.slug); }) // Stable OAuth redirect target for centralized MCP sign-in. The browser is // redirected here after the user consents; we exchange the code for tokens // (stored against the server) and show a self-closing confirmation page. The // Settings panel polls `mcp:status` to pick up the new connected state. .get('/api/mcp/oauth/callback', async ({ query }) => { const { code, state, error } = query as { code?: string; state?: string; error?: string }; const page = (title: string, body: string) => new Response( `${title}

${title}

${body}

`, { headers: { 'Content-Type': 'text/html' } } ); if (error) return page('Sign-in failed', `The authorization server returned: ${error}. You can close this tab and try again.`); if (!code || !state) return page('Sign-in failed', 'Missing authorization code. You can close this tab and try again.'); try { await completeAuthorization(state, code); return page('Connected', 'Sign-in complete. You can close this tab and return to Clopen.'); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; return page('Sign-in failed', `${message}. You can close this tab and try again.`); } }) // HTTP file transfer — mounted before the WS plugin so /api/files/* stays // on the HTTP path through the Vite dev proxy. .use(filesUploadRoute) .use(filesDownloadRoute) .use(filesSharedRoute) // Per-user notification sound upload/serve/delete. .use(audioRoute) // Web Push subscriptions + test (mobile background notifications). .use(pushRoute) // SSH file transfer (SFTP download/upload). .use(sshSftpRoute) // Notes images .use(notesImagesRoute) // Inbound third-party events. Unauthenticated by necessity — it verifies a // per-provider signature over the raw bytes instead of a session. .use(integrationHooksRoute) // Mount WebSocket router (all functionality now via WebSocket) .use(wsRouter.asPlugin('/ws')); if (!isDevelopment) { // Production: serve static files manually instead of @elysiajs/static. // The static plugin tries to serve directories (like /) as files via Bun.file(), // which hangs on some devices/platforms. Using statSync to verify the path is // an actual file before serving avoids this issue. const distDir = resolve(process.cwd(), 'dist'); const indexHtml = await Bun.file(resolve(distDir, 'index.html')).text(); app.all('/*', ({ path }) => { // Serve static files from dist/ if (path !== '/' && !path.includes('..')) { const filePath = resolve(distDir, path.slice(1)); if (filePath.startsWith(distDir)) { try { if (statSync(filePath).isFile()) { const file = Bun.file(filePath); return new Response(file, { headers: { 'Content-Type': file.type || 'application/octet-stream' } }); } } catch {} } } // SPA fallback: serve cached index.html return new Response(indexHtml, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); }); } // Start server with proper initialization sequence async function startServer() { // Port resolution is handled by the caller: // - Development: scripts/dev.ts resolves ports and passes via PORT_BACKEND env // - Production: scripts/start.ts resolves port and passes via PORT env // - CLI: bin/clopen.ts resolves port and passes via PORT env // This avoids double port-check race conditions (e.g. zombie processes on // Windows causing silent desync between Vite proxy and backend). // Enrich process.env.PATH with known install directories so Bun.which / // Bun.spawn / bun-pty discover CLI binaries regardless of how clopen was // launched (GUI, service, container, login shell). See path-enrich.ts. try { await refreshProcessPath(); } catch (error) { debug.warn('path', '⚠️ Initial PATH enrichment failed:', error); } // Initialize database first before accepting connections. // // A failure here is fatal on purpose. Serving requests without a database // means every settings read fails, and callers that fall back to defaults // report a configured instance as unconfigured — an empty project list, an // unfinished setup wizard. Refusing to start is both louder and safer. try { await initializeDatabase(); debug.log('database', `✅ Database initialized successfully (data dir: ${SERVER_ENV.DATA_DIR})`); // Re-establish code-defined built-ins that live outside migrations/seeders // (internal MCP servers like Browser Automation + the default engine). // Shared with the clear-data handler so a DB wipe on the live process // restores them without a restart. bootstrapAfterDbInit(); // Start expired session cleanup now that the database is ready sessionCleanupScheduler.start(); uploadTempCleanup.start(); // Watch for engine-affecting config edits and apply them on their own. // Long-lived, so it belongs here rather than in bootstrapAfterDbInit(), // which also runs on the live process during "Clear All Data". startEngineHotReload(); startEngineConfigWatcher(); // Sidebar count for the port manager. Idle-cheap by construction: with no // terminal sessions running there are no session-born ports to count, so // the tick skips the scan entirely. portMonitor.start(); // Sidebar count for the container manager: containers running on this // machine. Idle-cheap too — a machine with no runtime is remembered as // having none, so the tick costs a cached lookup and no command at all. containerMonitor.start(); } catch (error) { console.error('❌ Database initialization failed — refusing to start:', error); console.error(` Data directory: ${SERVER_ENV.DATA_DIR}`); process.exit(1); } // Start listening after database is ready app.listen({ port: PORT, hostname: HOST }, () => { if (isDevelopment) { console.log('🚀 Backend ready — waiting for frontend...'); } else { console.log(`🚀 Clopen running at http://localhost:${PORT}`); } if (HOST === '0.0.0.0') { const ips = getLocalIps(); for (const ip of ips) { console.log(`🌐 Network access: http://${ip}:${PORT}`); } } }); } startServer().catch((error) => { console.error('❌ Failed to start server:', error); process.exit(1); }); // Graceful shutdown - properly close server and database let isShuttingDown = false; async function gracefulShutdown() { if (isShuttingDown) return; isShuttingDown = true; // Force exit after 5 seconds — prevents port from being held by slow cleanup // during bun --watch restarts, which causes ECONNREFUSED on the Vite WS proxy. const forceExitTimer = setTimeout(() => { debug.warn('server', '⚠️ Shutdown timeout — forcing exit to release port'); process.exit(1); }, 5_000); console.log('\n🛑 Shutting down server...'); try { // Stop accepting new connections first — release the port ASAP app.stop(); // Release DB Client pools up front so remote server sessions are freed // immediately on restart (dev `bun --watch`), before the slower engine // and browser teardown below — otherwise the old process keeps those // sockets open long enough to overlap the new process ("too many clients"). await connectionManager.closeAll(); // Engines next, because they own CHILD PROCESSES. Everything below is // in-process cleanup that dies with us anyway, but an `opencode serve` // we fail to kill outlives the restart holding its port and data-dir // lock — and the 5s force-exit above used to fire before we got here. await disposeAllEngines(); // Same reasoning for SSH: stop the forwards' listeners and close every // transport so their remote sessions and bound ports are released now. await sshForwardManager.stopAll(); sshClientPool.closeAll(); // Dispose rate limiter timer authRateLimiter.dispose(); // Dispose expired session cleanup timer sessionCleanupScheduler.dispose(); // Dispose upload temp cleanup timer uploadTempCleanup.dispose(); // Stop port polling and release any SSH leases it holds portMonitor.stop(); // Same for the container list, and the log streams it may still be pumping stopAllContainerLogStreams(); containerMonitor.stop(); // Build-log follows are open HTTPS responses against a provider; nothing // closes them but us. stopAllBuildLogStreams(); // Close MCP remote server (before engines, as they may still reference it) await closeMcpServer(); // Cleanup browser preview sessions await browserPreviewServiceManager.cleanup(); // Write out any memory extraction still in flight or held back by a live // stream. Bounded, because each entry is a model call with no timeout of its // own — and the queue is a table, so whatever is left is picked up on the next // start rather than lost. stopMemoryMaintenance(); stopExtractionRunner(); await flushEpisodicIngest(); // Close database connection closeDatabase(); debug.log('server', '✅ Graceful shutdown completed'); } catch (error) { debug.error('server', '❌ Error during shutdown:', error); } clearTimeout(forceExitTimer); process.exit(0); } process.on('SIGINT', gracefulShutdown); process.on('SIGTERM', gracefulShutdown); // Ignore SIGHUP — sent when the controlling terminal closes or an SSH session // disconnects. Without a handler Bun exits immediately; we want the server to // keep running (e.g. started in a background tab or remote shell). process.on('SIGHUP', () => { debug.log('server', 'Received SIGHUP — ignoring (server stays running)'); }); // Safety net: prevent server crash from unhandled promise rejections. // These can occur when third-party SDKs emit asynchronous errors that bypass // the normal try/catch flow, e.g.: // - AI engine SDKs whose subprocess is killed during initialization // - puppeteer-extra's stealth evasions fire a non-awaited // `Network.setUserAgentOverride` on a newly created popup target; when the // target closes mid-call the resulting TargetCloseError rejects with no // handler attached and would otherwise kill the whole server while the user // is interacting with the Preview. // // IMPORTANT (Bun routing — verified on Bun 1.3.14): top-level unhandled // rejections are suppressed by Node's process.on('unhandledRejection') but NOT // by the Web API globalThis.addEventListener('unhandledrejection') + // preventDefault() (the Web handler does not even fire for these). We register // BOTH for cross-version safety; the shared reporter below dedups intent. function reportUnhandledRejection(reason: unknown): void { try { const message = reason instanceof Error ? reason.message : String(reason); if (message.includes('Operation aborted') || message.includes('aborted')) { debug.warn('server', 'Suppressed expected SDK abort rejection:', message); return; } debug.error('server', 'Unhandled promise rejection (server still running):', reason); } catch { console.error('Unhandled promise rejection (server still running)'); } } // Primary net on Bun 1.3.x — registering this handler prevents the default // crash-on-unhandled-rejection behavior. process.on('unhandledRejection', (reason) => { reportUnhandledRejection(reason); }); // Belt-and-suspenders for Bun versions where only the Web API suppresses exit. globalThis.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { event.preventDefault(); reportUnhandledRejection(event.reason); }); // Owns every `process.emitWarning` line in the server log (see the module for // why registering a listener means we must re-print what we keep). installProcessWarningFilter(); process.on('uncaughtException', (error) => { try { debug.error('server', 'Uncaught exception (server still running):', error); } catch { console.error('Uncaught exception (server still running)'); } });