fix(api): lazy-load node-pty to prevent API crash on missing native binary (#525)
All checks were successful
ci/woodpecker/push/api Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #525.
This commit is contained in:
2026-02-26 13:46:26 +00:00
committed by jason.woltje
parent d05b870f08
commit ad99cb9a03
3 changed files with 38 additions and 7 deletions

View File

@@ -31,7 +31,11 @@ COPY packages/config/package.json ./packages/config/
COPY apps/api/package.json ./apps/api/
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
RUN pnpm install --frozen-lockfile
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
# scripts or fail to find prebuilt binaries for this Node.js version
RUN pnpm install --frozen-lockfile \
&& cd node_modules/.pnpm/node-pty@*/node_modules/node-pty \
&& npx node-gyp rebuild 2>&1 || true
# ======================
# Builder stage

View File

@@ -46,7 +46,7 @@ describe("TerminalService", () => {
let service: TerminalService;
let mockSocket: Socket;
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
// Reset mock implementations
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
@@ -54,6 +54,8 @@ describe("TerminalService", () => {
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
);
service = new TerminalService();
// Trigger lazy import of node-pty (uses dynamic import(), intercepted by vi.mock)
await service.onModuleInit();
mockSocket = createMockSocket();
});

View File

@@ -13,11 +13,19 @@
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
*/
import { Injectable, Logger } from "@nestjs/common";
import * as pty from "node-pty";
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",
@@ -31,7 +39,7 @@ const DEFAULT_ROWS = 24;
export interface TerminalSession {
sessionId: string;
workspaceId: string;
pty: pty.IPty;
pty: IPty;
name?: string;
createdAt: Date;
}
@@ -53,7 +61,7 @@ export interface SessionCreatedResult {
}
@Injectable()
export class TerminalService {
export class TerminalService implements OnModuleInit {
private readonly logger = new Logger(TerminalService.name);
/**
@@ -66,13 +74,30 @@ export class TerminalService {
*/
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
* @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;