Archived
Add workspace terminal panel
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { RawData } from "ws";
|
||||
import type { TerminalService } from "./terminalService.js";
|
||||
|
||||
export function registerTerminalRoutes(app: FastifyInstance, terminals: TerminalService, prefix = ""): void {
|
||||
app.get<{ Querystring: { cwd?: string } }>(`${prefix}/terminals`, (request, reply) => {
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
return terminals.list(request.query.cwd);
|
||||
});
|
||||
|
||||
app.post<{ Body: { cwd: string; name?: string; cols?: number; rows?: number } }>(`${prefix}/terminals`, (request, reply) => {
|
||||
try {
|
||||
return terminals.create(request.body);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { terminalId: string } }>(`${prefix}/terminals/:terminalId`, (request) => {
|
||||
terminals.close(request.params.terminalId);
|
||||
return { closed: true };
|
||||
});
|
||||
|
||||
app.get<{ Params: { terminalId: string } }>(`${prefix}/terminals/:terminalId/socket`, { websocket: true }, (socket, request) => {
|
||||
let detach: (() => void) | undefined;
|
||||
try {
|
||||
detach = terminals.attach(request.params.terminalId, {
|
||||
output: (data) => { socket.send(JSON.stringify({ type: "output", data })); },
|
||||
exit: (exitCode) => { socket.send(JSON.stringify({ type: "exit", exitCode })); },
|
||||
});
|
||||
} catch (error) {
|
||||
socket.send(JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }));
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.on("message", (data) => {
|
||||
try {
|
||||
const message = parseClientMessage(data);
|
||||
if (message.type === "input") terminals.write(request.params.terminalId, message.data);
|
||||
if (message.type === "resize") terminals.resize(request.params.terminalId, message.cols, message.rows);
|
||||
} catch (error) {
|
||||
socket.send(JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) }));
|
||||
}
|
||||
});
|
||||
socket.on("close", () => { detach(); });
|
||||
socket.on("error", () => { detach(); });
|
||||
});
|
||||
}
|
||||
|
||||
type ClientTerminalMessage =
|
||||
| { type: "input"; data: string }
|
||||
| { type: "resize"; cols: number; rows: number };
|
||||
|
||||
function parseClientMessage(data: RawData): ClientTerminalMessage {
|
||||
const value: unknown = JSON.parse(rawDataToString(data));
|
||||
if (!isRecord(value) || typeof value["type"] !== "string") throw new Error("Invalid terminal message");
|
||||
if (value["type"] === "input" && typeof value["data"] === "string") return { type: "input", data: value["data"] };
|
||||
if (value["type"] === "resize" && typeof value["cols"] === "number" && typeof value["rows"] === "number") return { type: "resize", cols: value["cols"], rows: value["rows"] };
|
||||
throw new Error("Invalid terminal message");
|
||||
}
|
||||
|
||||
function rawDataToString(data: RawData): string {
|
||||
if (typeof data === "string") return data;
|
||||
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
||||
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
|
||||
return data.toString("utf8");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as pty from "node-pty";
|
||||
|
||||
const MAX_REPLAY_BUFFER = 200_000;
|
||||
|
||||
export interface TerminalInfo {
|
||||
id: string;
|
||||
cwd: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
exited: boolean;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
interface TerminalRecord extends TerminalInfo {
|
||||
pty: pty.IPty;
|
||||
buffer: string;
|
||||
events: EventEmitter;
|
||||
}
|
||||
|
||||
export class TerminalService {
|
||||
private readonly terminals = new Map<string, TerminalRecord>();
|
||||
|
||||
list(cwd: string): TerminalInfo[] {
|
||||
return [...this.terminals.values()]
|
||||
.filter((terminal) => terminal.cwd === cwd)
|
||||
.map(toInfo);
|
||||
}
|
||||
|
||||
create(options: { cwd: string; name?: string; cols?: number; rows?: number }): TerminalInfo {
|
||||
if (options.cwd === "") throw new Error("cwd is required");
|
||||
const id = randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
const shell = process.env["SHELL"] ?? "/bin/bash";
|
||||
const terminal = pty.spawn(shell, [], {
|
||||
name: "xterm-256color",
|
||||
cwd: options.cwd,
|
||||
cols: options.cols ?? 100,
|
||||
rows: options.rows ?? 30,
|
||||
env: { ...process.env, TERM: "xterm-256color" },
|
||||
});
|
||||
const requestedName = options.name?.trim();
|
||||
const record: TerminalRecord = {
|
||||
id,
|
||||
cwd: options.cwd,
|
||||
name: requestedName !== undefined && requestedName !== "" ? requestedName : `Shell ${String(this.list(options.cwd).length + 1)}`,
|
||||
createdAt,
|
||||
exited: false,
|
||||
pty: terminal,
|
||||
buffer: "",
|
||||
events: new EventEmitter(),
|
||||
};
|
||||
terminal.onData((data) => {
|
||||
record.buffer = trimReplayBuffer(record.buffer + data);
|
||||
record.events.emit("output", data);
|
||||
});
|
||||
terminal.onExit(({ exitCode }) => {
|
||||
record.exited = true;
|
||||
record.exitCode = exitCode;
|
||||
record.events.emit("exit", exitCode);
|
||||
});
|
||||
this.terminals.set(id, record);
|
||||
return toInfo(record);
|
||||
}
|
||||
|
||||
get(id: string): TerminalInfo | undefined {
|
||||
const terminal = this.terminals.get(id);
|
||||
return terminal === undefined ? undefined : toInfo(terminal);
|
||||
}
|
||||
|
||||
attach(id: string, handlers: { output: (data: string) => void; exit: (exitCode: number | undefined) => void }): () => void {
|
||||
const terminal = this.require(id);
|
||||
if (terminal.buffer !== "") handlers.output(terminal.buffer);
|
||||
if (terminal.exited) handlers.exit(terminal.exitCode);
|
||||
const onOutput = (data: string) => { handlers.output(data); };
|
||||
const onExit = (exitCode: number | undefined) => { handlers.exit(exitCode); };
|
||||
terminal.events.on("output", onOutput);
|
||||
terminal.events.on("exit", onExit);
|
||||
return () => {
|
||||
terminal.events.off("output", onOutput);
|
||||
terminal.events.off("exit", onExit);
|
||||
};
|
||||
}
|
||||
|
||||
write(id: string, data: string): void {
|
||||
const terminal = this.require(id);
|
||||
if (!terminal.exited) terminal.pty.write(data);
|
||||
}
|
||||
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
const terminal = this.require(id);
|
||||
if (!terminal.exited && Number.isFinite(cols) && Number.isFinite(rows) && cols > 0 && rows > 0) {
|
||||
terminal.pty.resize(Math.floor(cols), Math.floor(rows));
|
||||
}
|
||||
}
|
||||
|
||||
close(id: string): void {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal === undefined) return;
|
||||
this.terminals.delete(id);
|
||||
terminal.events.removeAllListeners();
|
||||
if (!terminal.exited) terminal.pty.kill();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const id of [...this.terminals.keys()]) this.close(id);
|
||||
}
|
||||
|
||||
private require(id: string): TerminalRecord {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal === undefined) throw new Error("Terminal not found");
|
||||
return terminal;
|
||||
}
|
||||
}
|
||||
|
||||
function toInfo(record: TerminalRecord): TerminalInfo {
|
||||
return {
|
||||
id: record.id,
|
||||
cwd: record.cwd,
|
||||
name: record.name,
|
||||
createdAt: record.createdAt,
|
||||
exited: record.exited,
|
||||
...(record.exitCode === undefined ? {} : { exitCode: record.exitCode }),
|
||||
};
|
||||
}
|
||||
|
||||
function trimReplayBuffer(buffer: string): string {
|
||||
if (buffer.length <= MAX_REPLAY_BUFFER) return buffer;
|
||||
return buffer.slice(buffer.length - MAX_REPLAY_BUFFER);
|
||||
}
|
||||
Reference in New Issue
Block a user