feat(KB-058): complete Step 3 — add WebSocket terminal routes and REST endpoints
This commit is contained in:
@@ -7,6 +7,7 @@ import { COLUMNS, VALID_TRANSITIONS, type PrInfo } from "@kb/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, getCurrentGitHubRepo } from "./github.js";
|
||||
import { terminalSessionManager } from "./terminal.js";
|
||||
import { getTerminalService } from "./terminal-service.js";
|
||||
import { listFiles, readFile, writeFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
|
||||
import { fetchAllProviderUsage } from "./usage.js";
|
||||
|
||||
@@ -1850,6 +1851,91 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── PTY Terminal Routes (WebSocket-based) ────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/terminal/sessions
|
||||
* Create a new PTY terminal session.
|
||||
* Body: { cwd?: string, cols?: number, rows?: number }
|
||||
* Returns: { sessionId: string, shell: string, cwd: string }
|
||||
*/
|
||||
router.post("/terminal/sessions", async (req, res) => {
|
||||
try {
|
||||
const { cwd, cols, rows } = req.body;
|
||||
const terminalService = getTerminalService(store.getRootDir());
|
||||
|
||||
const session = await terminalService.createSession({
|
||||
cwd,
|
||||
cols: typeof cols === "number" ? cols : undefined,
|
||||
rows: typeof rows === "number" ? rows : undefined,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
res.status(503).json({ error: "Failed to create session. Max sessions may be reached." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
sessionId: session.id,
|
||||
shell: session.shell,
|
||||
cwd: session.cwd,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || "Failed to create terminal session" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/terminal/sessions
|
||||
* List all active PTY terminal sessions.
|
||||
* Returns: [{ id: string, cwd: string, shell: string, createdAt: string }]
|
||||
*/
|
||||
router.get("/terminal/sessions", async (_req, res) => {
|
||||
try {
|
||||
const terminalService = getTerminalService(store.getRootDir());
|
||||
const sessions = terminalService.getAllSessions();
|
||||
|
||||
res.json(
|
||||
sessions.map((s) => ({
|
||||
id: s.id,
|
||||
cwd: s.cwd,
|
||||
shell: s.shell,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
}))
|
||||
);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || "Failed to list sessions" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/terminal/sessions/:id
|
||||
* Kill a PTY terminal session.
|
||||
* Returns: { killed: boolean }
|
||||
*/
|
||||
router.delete("/terminal/sessions/:id", (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const terminalService = getTerminalService(store.getRootDir());
|
||||
|
||||
const killed = terminalService.killSession(id);
|
||||
|
||||
if (!killed) {
|
||||
const session = terminalService.getSession(id);
|
||||
if (!session) {
|
||||
res.status(404).json({ error: "Session not found" });
|
||||
} else {
|
||||
res.status(400).json({ error: "Failed to kill session" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ killed: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── File API Routes ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { createSSE } from "./sse.js";
|
||||
import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
|
||||
import { getTerminalService, type TerminalSession } from "./terminal-service.js";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { terminalSessionManager } from "./terminal.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -28,6 +30,9 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Initialize terminal service with project root
|
||||
const terminalService = getTerminalService(store.getRootDir());
|
||||
|
||||
// Serve built React app
|
||||
// Resolution order:
|
||||
// 1. KB_CLIENT_DIR env override (explicit)
|
||||
@@ -77,7 +82,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
});
|
||||
});
|
||||
|
||||
// Terminal SSE endpoint for real-time command output streaming
|
||||
// Legacy Terminal SSE endpoint (deprecated, use WebSocket instead)
|
||||
app.get("/api/terminal/sessions/:id/stream", rateLimit(RATE_LIMITS.sse), (req, res) => {
|
||||
const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
|
||||
@@ -147,5 +152,145 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
res.sendFile(join(clientDir, "index.html"));
|
||||
});
|
||||
|
||||
// Store WebSocket server reference for external mounting
|
||||
(app as ReturnType<typeof express> & { wsServer?: WebSocketServer }).wsServer = null as unknown as WebSocketServer;
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup WebSocket terminal server
|
||||
* Call this after creating the HTTP server to attach WebSocket handling
|
||||
*/
|
||||
export function setupTerminalWebSocket(
|
||||
app: ReturnType<typeof express>,
|
||||
server: import("http").Server
|
||||
): void {
|
||||
const terminalService = getTerminalService();
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
path: "/api/terminal/ws",
|
||||
});
|
||||
|
||||
// Store reference on app for access
|
||||
(app as ReturnType<typeof express> & { wsServer?: WebSocketServer }).wsServer = wss;
|
||||
|
||||
wss.on("connection", (ws: WebSocket, req) => {
|
||||
// Parse query params from URL
|
||||
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||
const sessionId = url.searchParams.get("sessionId");
|
||||
|
||||
if (!sessionId) {
|
||||
ws.close(4000, "Missing sessionId");
|
||||
return;
|
||||
}
|
||||
|
||||
const session = terminalService.getSession(sessionId);
|
||||
if (!session) {
|
||||
ws.close(4004, "Session not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Track if connection is alive
|
||||
let isAlive = true;
|
||||
let dataUnsub: (() => void) | null = null;
|
||||
let exitUnsub: (() => void) | null = null;
|
||||
|
||||
// Send scrollback buffer first
|
||||
const scrollback = terminalService.getScrollbackAndClearPending(sessionId);
|
||||
if (scrollback) {
|
||||
ws.send(JSON.stringify({ type: "scrollback", data: scrollback }));
|
||||
}
|
||||
|
||||
// Send connection info
|
||||
ws.send(JSON.stringify({
|
||||
type: "connected",
|
||||
shell: session.shell,
|
||||
cwd: session.cwd,
|
||||
}));
|
||||
|
||||
// Subscribe to data events
|
||||
dataUnsub = terminalService.onData((id, data) => {
|
||||
if (id === sessionId && isAlive) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: "data", data }));
|
||||
} catch {
|
||||
// WebSocket might be closing
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to exit events
|
||||
exitUnsub = terminalService.onExit((id, exitCode) => {
|
||||
if (id === sessionId && isAlive) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: "exit", exitCode }));
|
||||
} catch {
|
||||
// WebSocket might be closing
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Heartbeat ping/pong
|
||||
const pingInterval = setInterval(() => {
|
||||
if (!isAlive) {
|
||||
ws.terminate();
|
||||
return;
|
||||
}
|
||||
isAlive = false;
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
} catch {
|
||||
ws.terminate();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
ws.on("pong", () => {
|
||||
isAlive = true;
|
||||
});
|
||||
|
||||
ws.on("message", (message: Buffer) => {
|
||||
try {
|
||||
const msg = JSON.parse(message.toString());
|
||||
|
||||
switch (msg.type) {
|
||||
case "input":
|
||||
if (typeof msg.data === "string") {
|
||||
terminalService.write(sessionId, msg.data);
|
||||
}
|
||||
break;
|
||||
case "resize":
|
||||
if (typeof msg.cols === "number" && typeof msg.rows === "number") {
|
||||
terminalService.resize(sessionId, msg.cols, msg.rows);
|
||||
}
|
||||
break;
|
||||
case "ping":
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
case "pong":
|
||||
isAlive = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
isAlive = false;
|
||||
clearInterval(pingInterval);
|
||||
if (dataUnsub) dataUnsub();
|
||||
if (exitUnsub) exitUnsub();
|
||||
});
|
||||
|
||||
ws.on("error", () => {
|
||||
isAlive = false;
|
||||
clearInterval(pingInterval);
|
||||
if (dataUnsub) dataUnsub();
|
||||
if (exitUnsub) exitUnsub();
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`Terminal WebSocket server mounted at /api/terminal/ws`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user