#!/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}`,
{ 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)');
}
});