Files
claude-code/packages/remote-control-server/src/index.ts
2026-05-01 21:39:30 +08:00

140 lines
4.8 KiB
TypeScript

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'))