Files
claude-code/packages/remote-control-server/src/index.ts
2026-04-19 08:55:55 +08:00

130 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";
// 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());
// 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"));