Files
stack/apps/api/src/terminal/terminal.service.ts
Jason Woltje ad99cb9a03
All checks were successful
ci/woodpecker/push/api Pipeline was successful
fix(api): lazy-load node-pty to prevent API crash on missing native binary (#525)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-26 13:46:26 +00:00

277 lines
8.3 KiB
TypeScript

/**
* 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, OnModuleInit } from "@nestjs/common";
import type { IPty } from "node-pty";
import type { Socket } from "socket.io";
import { randomUUID } from "node:crypto";
// Lazy-loaded in onModuleInit via dynamic import() to prevent crash
// if the native binary is missing. node-pty requires a compiled .node
// binary which may not be available in all Docker environments.
interface NodePtyModule {
spawn: (file: string, args: string[], options: Record<string, unknown>) => IPty;
}
let pty: NodePtyModule | null = null;
/** 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: 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 implements OnModuleInit {
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>>();
async onModuleInit(): Promise<void> {
if (!pty) {
try {
pty = await import("node-pty");
this.logger.log("node-pty loaded successfully — terminal sessions available");
} catch {
this.logger.warn(
"node-pty native module not available — terminal sessions will be disabled. " +
"Install build tools (python3, make, g++) and rebuild node-pty to enable."
);
}
}
}
/**
* 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 or node-pty is unavailable
*/
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
if (!pty) {
throw new Error("Terminal sessions are unavailable: node-pty native module failed to load");
}
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);
}
}
}
}