feat(api): add terminal WebSocket gateway with PTY session management (#515)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #515.
This commit is contained in:
251
apps/api/src/terminal/terminal.service.ts
Normal file
251
apps/api/src/terminal/terminal.service.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* TerminalService
|
||||
*
|
||||
* Manages PTY (pseudo-terminal) sessions for workspace users.
|
||||
* Spawns real shell processes via node-pty, streams I/O to connected sockets,
|
||||
* and enforces per-workspace session limits.
|
||||
*
|
||||
* Session lifecycle:
|
||||
* - createSession: spawn a new PTY, wire onData/onExit, return sessionId
|
||||
* - writeToSession: send input data to PTY stdin
|
||||
* - resizeSession: resize PTY dimensions (cols x rows)
|
||||
* - closeSession: kill PTY process, emit terminal:exit, cleanup
|
||||
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import * as pty from "node-pty";
|
||||
import type { Socket } from "socket.io";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
/** Maximum concurrent PTY sessions per workspace */
|
||||
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
|
||||
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
|
||||
10
|
||||
);
|
||||
|
||||
/** Default PTY dimensions */
|
||||
const DEFAULT_COLS = 80;
|
||||
const DEFAULT_ROWS = 24;
|
||||
|
||||
export interface TerminalSession {
|
||||
sessionId: string;
|
||||
workspaceId: string;
|
||||
pty: pty.IPty;
|
||||
name?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
name?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
cwd?: string;
|
||||
workspaceId: string;
|
||||
socketId: string;
|
||||
}
|
||||
|
||||
export interface SessionCreatedResult {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TerminalService {
|
||||
private readonly logger = new Logger(TerminalService.name);
|
||||
|
||||
/**
|
||||
* Map of sessionId -> TerminalSession
|
||||
*/
|
||||
private readonly sessions = new Map<string, TerminalSession>();
|
||||
|
||||
/**
|
||||
* Map of workspaceId -> Set<sessionId> for fast per-workspace lookups
|
||||
*/
|
||||
private readonly workspaceSessions = new Map<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* Create a new PTY session for the given workspace and socket.
|
||||
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
|
||||
*
|
||||
* @throws Error if workspace session limit is exceeded
|
||||
*/
|
||||
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
|
||||
const { workspaceId, name, cwd, socketId } = options;
|
||||
const cols = options.cols ?? DEFAULT_COLS;
|
||||
const rows = options.rows ?? DEFAULT_ROWS;
|
||||
|
||||
// Enforce per-workspace session limit
|
||||
const workspaceSessionIds = this.workspaceSessions.get(workspaceId) ?? new Set<string>();
|
||||
if (workspaceSessionIds.size >= MAX_SESSIONS_PER_WORKSPACE) {
|
||||
throw new Error(
|
||||
`Workspace ${workspaceId} has reached the maximum of ${String(MAX_SESSIONS_PER_WORKSPACE)} concurrent terminal sessions`
|
||||
);
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const shell = process.env.SHELL ?? "/bin/bash";
|
||||
|
||||
this.logger.log(
|
||||
`Spawning PTY session ${sessionId} for workspace ${workspaceId} (socket: ${socketId}, shell: ${shell}, ${String(cols)}x${String(rows)})`
|
||||
);
|
||||
|
||||
const ptyProcess = pty.spawn(shell, [], {
|
||||
name: "xterm-256color",
|
||||
cols,
|
||||
rows,
|
||||
cwd: cwd ?? process.cwd(),
|
||||
env: process.env as Record<string, string>,
|
||||
});
|
||||
|
||||
const session: TerminalSession = {
|
||||
sessionId,
|
||||
workspaceId,
|
||||
pty: ptyProcess,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Track by workspace
|
||||
if (!this.workspaceSessions.has(workspaceId)) {
|
||||
this.workspaceSessions.set(workspaceId, new Set());
|
||||
}
|
||||
const wsSet = this.workspaceSessions.get(workspaceId);
|
||||
if (wsSet) {
|
||||
wsSet.add(sessionId);
|
||||
}
|
||||
|
||||
// Wire PTY stdout/stderr -> terminal:output
|
||||
ptyProcess.onData((data: string) => {
|
||||
socket.emit("terminal:output", { sessionId, data });
|
||||
});
|
||||
|
||||
// Wire PTY exit -> terminal:exit, cleanup
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
this.logger.log(
|
||||
`PTY session ${sessionId} exited (exitCode: ${String(exitCode)}, signal: ${String(signal ?? "none")})`
|
||||
);
|
||||
socket.emit("terminal:exit", { sessionId, exitCode, signal });
|
||||
this.cleanupSession(sessionId, workspaceId);
|
||||
});
|
||||
|
||||
return { sessionId, ...(name !== undefined ? { name } : {}), cols, rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write input data to a PTY session's stdin.
|
||||
*
|
||||
* @throws Error if session not found
|
||||
*/
|
||||
writeToSession(sessionId: string, data: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Terminal session ${sessionId} not found`);
|
||||
}
|
||||
session.pty.write(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a PTY session's terminal dimensions.
|
||||
*
|
||||
* @throws Error if session not found
|
||||
*/
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Terminal session ${sessionId} not found`);
|
||||
}
|
||||
session.pty.resize(cols, rows);
|
||||
this.logger.debug(`Resized PTY session ${sessionId} to ${String(cols)}x${String(rows)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill and clean up a specific PTY session.
|
||||
* Returns true if the session existed, false if it was already gone.
|
||||
*/
|
||||
closeSession(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.log(`Closing PTY session ${sessionId} for workspace ${session.workspaceId}`);
|
||||
|
||||
try {
|
||||
session.pty.kill();
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Error killing PTY session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.cleanupSession(sessionId, session.workspaceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all PTY sessions for a workspace (called on client disconnect).
|
||||
*/
|
||||
closeWorkspaceSessions(workspaceId: string): void {
|
||||
const sessionIds = this.workspaceSessions.get(workspaceId);
|
||||
if (!sessionIds || sessionIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Closing ${String(sessionIds.size)} PTY session(s) for workspace ${workspaceId} (disconnect)`
|
||||
);
|
||||
|
||||
// Copy to array to avoid mutation during iteration
|
||||
const ids = Array.from(sessionIds);
|
||||
for (const sessionId of ids) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
try {
|
||||
session.pty.kill();
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Error killing PTY session ${sessionId} on disconnect: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
this.cleanupSession(sessionId, workspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of active sessions for a workspace.
|
||||
*/
|
||||
getWorkspaceSessionCount(workspaceId: string): number {
|
||||
return this.workspaceSessions.get(workspaceId)?.size ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session belongs to a given workspace.
|
||||
* Used for access control in the gateway.
|
||||
*/
|
||||
sessionBelongsToWorkspace(sessionId: string, workspaceId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
return session?.workspaceId === workspaceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal cleanup: remove session from tracking maps.
|
||||
* Does NOT kill the PTY (caller is responsible).
|
||||
*/
|
||||
private cleanupSession(sessionId: string, workspaceId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
const workspaceSessionIds = this.workspaceSessions.get(workspaceId);
|
||||
if (workspaceSessionIds) {
|
||||
workspaceSessionIds.delete(sessionId);
|
||||
if (workspaceSessionIds.size === 0) {
|
||||
this.workspaceSessions.delete(workspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user