import { Hono } from 'hono' import { cors } from 'hono/cors' import { logger } from 'hono/logger' import { serveStatic } from 'hono/bun' import { config } from './config' import { closeAllConnections } from './transport/ws-handler' import { closeAllAcpConnections } from './transport/acp-ws-handler' import { closeAllRelayConnections } from './transport/acp-relay-handler' import { startDisconnectMonitor } from './services/disconnect-monitor' import { dirname, resolve } from 'node:path' import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import acpRoutes from './routes/acp' import { webCorsOptions } from './auth/cors' // Routes import v1Environments from './routes/v1/environments' import v1EnvironmentsWork from './routes/v1/environments.work' import v1Sessions from './routes/v1/sessions' import v1SessionIngress from './routes/v1/session-ingress' import { websocket } from './transport/ws-shared' import v2CodeSessions from './routes/v2/code-sessions' import v2Worker from './routes/v2/worker' import v2WorkerEventsStream from './routes/v2/worker-events-stream' import v2WorkerEvents from './routes/v2/worker-events' import webAuth from './routes/web/auth' import webSessions from './routes/web/sessions' import webControl from './routes/web/control' import webEnvironments from './routes/web/environments' console.log('[RCS] In-memory store ready (no SQLite)') const app = new Hono() // Middleware app.use('*', logger()) app.use('*', async (c, next) => { // Normalize double slashes in path (e.g. //v1/environments/bridge → /v1/environments/bridge) const path = new URL(c.req.url).pathname if (path.includes('//')) { const normalized = path.replace(/\/+/g, '/') const url = new URL(c.req.url) url.pathname = normalized return app.fetch(new Request(url.toString(), c.req.raw)) } await next() }) app.use('/web/*', cors(webCorsOptions)) // Health check app.get('/health', c => c.json({ status: 'ok', version: config.version })) // Static files — serve built web UI under /code path // Uses web/dist/ if it exists (production), otherwise falls back to web/ (dev/fallback) const __dirname = dirname(fileURLToPath(import.meta.url)) const distDir = resolve(__dirname, '../web/dist') const webDir = existsSync(resolve(distDir, 'index.html')) ? distDir : resolve(__dirname, '../web') const stripCodePrefix = (p: string) => p.replace(/^\/code/, '') // Serve all static files under /code/* from web/ directory app.use( '/code/*', serveStatic({ root: webDir, rewriteRequestPath: stripCodePrefix }), ) // /code, /code/, and /code/:sessionId — SPA fallback app.get('/code', serveStatic({ root: webDir, path: 'index.html' })) app.get('/code/', serveStatic({ root: webDir, path: 'index.html' })) app.get('/code/:sessionId', serveStatic({ root: webDir, path: 'index.html' })) // v1 Environment routes app.route('/v1/environments', v1Environments) app.route('/v1/environments', v1EnvironmentsWork) // v1 Session routes app.route('/v1/sessions', v1Sessions) // Session Ingress (WebSocket) — mounted at both /v1 and /v2 so the bridge // client's buildSdkUrl works with or without an Envoy proxy rewriting /v1→/v2. app.route('/v1/session_ingress', v1SessionIngress) app.route('/v2/session_ingress', v1SessionIngress) // v2 Code Sessions routes app.route('/v1/code/sessions', v2CodeSessions) app.route('/v1/code/sessions', v2Worker) app.route('/v1/code/sessions', v2WorkerEventsStream) app.route('/v1/code/sessions', v2WorkerEvents) // Web control panel routes app.route('/web', webAuth) app.route('/web', webSessions) app.route('/web', webControl) app.route('/web', webEnvironments) // ACP protocol routes console.log('[RCS] ACP support enabled') app.route('/acp', acpRoutes) const port = config.port const host = config.host console.log(`[RCS] Remote Control Server starting on ${host}:${port}`) console.log('[RCS] API key configuration loaded') console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`) console.log(`[RCS] Disconnect timeout: ${config.disconnectTimeout}s`) console.log( `[RCS] WebSocket idle timeout: ${config.wsIdleTimeout}s (protocol-level pings)`, ) console.log( `[RCS] WebSocket keepalive interval: ${config.wsKeepaliveInterval}s (data frames)`, ) // Start disconnect monitor startDisconnectMonitor() export default { port, hostname: host, fetch: app.fetch, websocket: { ...websocket, idleTimeout: config.wsIdleTimeout, // Bun sends protocol pings after this many seconds of silence }, idleTimeout: config.wsIdleTimeout, // HTTP server idle timeout (seconds) } // Graceful shutdown async function gracefulShutdown(signal: string) { console.log(`\n[RCS] Received ${signal}, shutting down...`) closeAllConnections() closeAllAcpConnections() closeAllRelayConnections() process.exit(0) } process.on('SIGINT', () => gracefulShutdown('SIGINT')) process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))