From 64908098cd07c920b6a95603c11527fe01b72e0d Mon Sep 17 00:00:00 2001 From: plenarius Date: Mon, 20 Apr 2026 19:32:02 -0400 Subject: [PATCH] feat: add integrated terminal with xterm.js and shell sessions --- package-lock.json | 17 ++++ packages/backend/src/index.ts | 19 ++++ packages/backend/src/routes/terminal.ts | 25 ++++++ .../backend/src/services/terminal.service.ts | 52 +++++++++++ packages/frontend/package.json | 2 + .../src/components/terminal/Terminal.tsx | 86 +++++++++++++++++++ packages/frontend/src/layouts/IDELayout.tsx | 51 ++++++++--- 7 files changed, 242 insertions(+), 10 deletions(-) create mode 100644 packages/backend/src/routes/terminal.ts create mode 100644 packages/backend/src/services/terminal.service.ts create mode 100644 packages/frontend/src/components/terminal/Terminal.tsx diff --git a/package-lock.json b/package-lock.json index 19ae112..fe7aefe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1743,6 +1743,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -4787,6 +4802,8 @@ "version": "0.0.1", "dependencies": { "@monaco-editor/react": "^4.7.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "monaco-editor": "^0.55.1", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index d54ba22..7b4a647 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -3,10 +3,13 @@ import cors from "cors"; import { createServer } from "http"; import path from "path"; import { fileURLToPath } from "url"; +import { WebSocketServer } from "ws"; import { initWebSocket, getClientCount } from "./services/ws.service.js"; import workspaceRouter from "./routes/workspace.js"; import dockerRouter from "./routes/docker.js"; import editorRouter from "./routes/editor.js"; +import terminalRouter from "./routes/terminal.js"; +import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); @@ -28,6 +31,7 @@ app.get("/api/health", (_req, res) => { app.use("/api/workspace", workspaceRouter); app.use("/api/docker", dockerRouter); app.use("/api/editor", editorRouter); +app.use("/api/terminal", terminalRouter); const frontendDist = path.resolve(__dirname, "../../frontend/dist"); app.use(express.static(frontendDist)); @@ -35,6 +39,21 @@ app.get("*path", (_req, res) => { res.sendFile(path.join(frontendDist, "index.html")); }); +server.on("upgrade", (request, socket, head) => { + const url = new URL(request.url || "", `http://${request.headers.host}`); + const termMatch = url.pathname.match(/^\/ws\/terminal\/(.+)$/); + if (termMatch) { + const sessionId = termMatch[1]; + const termWss = new WebSocketServer({ noServer: true }); + termWss.handleUpgrade(request, socket, head, (ws) => { + if (!attachWebSocket(sessionId, ws)) { + createTerminalSession(sessionId); + attachWebSocket(sessionId, ws); + } + }); + } +}); + const PORT = parseInt(process.env.PORT || "3000", 10); server.listen(PORT, "0.0.0.0", () => { console.log(`Layonara Forge listening on http://0.0.0.0:${PORT}`); diff --git a/packages/backend/src/routes/terminal.ts b/packages/backend/src/routes/terminal.ts new file mode 100644 index 0000000..ff4381d --- /dev/null +++ b/packages/backend/src/routes/terminal.ts @@ -0,0 +1,25 @@ +import { Router } from "express"; +import { + createTerminalSession, + listSessions, + destroySession, +} from "../services/terminal.service.js"; + +const router = Router(); + +router.get("/sessions", (_req, res) => { + res.json({ sessions: listSessions() }); +}); + +router.post("/sessions", (req, res) => { + const id = (req.body.id as string) || `term-${Date.now()}`; + createTerminalSession(id); + res.json({ id }); +}); + +router.delete("/sessions/:id", (req, res) => { + destroySession(req.params.id); + res.json({ ok: true }); +}); + +export default router; diff --git a/packages/backend/src/services/terminal.service.ts b/packages/backend/src/services/terminal.service.ts new file mode 100644 index 0000000..ca09b85 --- /dev/null +++ b/packages/backend/src/services/terminal.service.ts @@ -0,0 +1,52 @@ +import { spawn, ChildProcess } from "child_process"; +import { WebSocket } from "ws"; + +interface TerminalSession { + id: string; + process: ChildProcess; + ws: WebSocket | null; +} + +const sessions = new Map(); + +export function createTerminalSession(id: string): TerminalSession { + const proc = spawn("/bin/bash", ["-l"], { + env: { ...process.env, TERM: "xterm-256color" }, + cwd: process.env.WORKSPACE_PATH || "/workspace", + }); + const session: TerminalSession = { id, process: proc, ws: null }; + sessions.set(id, session); + proc.on("exit", () => sessions.delete(id)); + return session; +} + +export function attachWebSocket(sessionId: string, ws: WebSocket): boolean { + const session = sessions.get(sessionId); + if (!session) return false; + session.ws = ws; + session.process.stdout?.on("data", (data: Buffer) => { + if (ws.readyState === WebSocket.OPEN) ws.send(data.toString()); + }); + session.process.stderr?.on("data", (data: Buffer) => { + if (ws.readyState === WebSocket.OPEN) ws.send(data.toString()); + }); + ws.on("message", (data) => { + session.process.stdin?.write(data.toString()); + }); + ws.on("close", () => { + session.ws = null; + }); + return true; +} + +export function destroySession(id: string): void { + const session = sessions.get(id); + if (session) { + session.process.kill(); + sessions.delete(id); + } +} + +export function listSessions(): string[] { + return Array.from(sessions.keys()); +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index aff636b..18e27b9 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@monaco-editor/react": "^4.7.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "monaco-editor": "^0.55.1", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/frontend/src/components/terminal/Terminal.tsx b/packages/frontend/src/components/terminal/Terminal.tsx new file mode 100644 index 0000000..740046b --- /dev/null +++ b/packages/frontend/src/components/terminal/Terminal.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef } from "react"; +import { Terminal as XTerm } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import "@xterm/xterm/css/xterm.css"; + +interface TerminalProps { + sessionId: string; +} + +export function Terminal({ sessionId }: TerminalProps) { + const containerRef = useRef(null); + const termRef = useRef(null); + const wsRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + const term = new XTerm({ + theme: { + background: "#121212", + foreground: "#f2f2f2", + cursor: "#946200", + selectionBackground: "#946200", + selectionForeground: "#f2f2f2", + black: "#121212", + brightBlack: "#666666", + white: "#f2f2f2", + brightWhite: "#ffffff", + yellow: "#946200", + brightYellow: "#c48800", + }, + fontFamily: "'JetBrains Mono', 'Fira Code', monospace", + fontSize: 13, + cursorBlink: true, + }); + + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.open(containerRef.current); + fitAddon.fit(); + termRef.current = term; + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${protocol}//${window.location.host}/ws/terminal/${sessionId}`); + wsRef.current = ws; + + ws.onopen = () => { + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) ws.send(data); + }); + }; + + ws.onmessage = (event) => { + term.write(event.data); + }; + + ws.onerror = () => { + term.write("\r\n\x1b[31m[Connection error]\x1b[0m\r\n"); + }; + + ws.onclose = () => { + term.write("\r\n\x1b[90m[Disconnected]\x1b[0m\r\n"); + }; + + const resizeObserver = new ResizeObserver(() => { + fitAddon.fit(); + }); + resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + ws.close(); + term.dispose(); + termRef.current = null; + wsRef.current = null; + }; + }, [sessionId]); + + return ( +
+ ); +} diff --git a/packages/frontend/src/layouts/IDELayout.tsx b/packages/frontend/src/layouts/IDELayout.tsx index 7ad3f42..6daa6bd 100644 --- a/packages/frontend/src/layouts/IDELayout.tsx +++ b/packages/frontend/src/layouts/IDELayout.tsx @@ -1,6 +1,10 @@ +import { useState } from "react"; import { Outlet } from "react-router-dom"; +import { Terminal } from "../components/terminal/Terminal"; export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) { + const [terminalOpen, setTerminalOpen] = useState(false); + return (
-
- {sidebar && ( - + +
)} -
- -
);