From d18cf445468e2558ae8f110a7a56fc9a5b027199 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 26 Feb 2026 07:44:47 -0600 Subject: [PATCH] fix(api): lazy-load node-pty to prevent API crash when native binary is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node-pty requires a compiled native addon (.node binary) that may not be available in all Docker environments. The eager import crashed the entire API at startup. Changed to dynamic import() in onModuleInit() so the service degrades gracefully — terminal sessions are disabled but all other API functionality works. Also added explicit node-gyp rebuild to Dockerfile deps stage since pnpm may skip postinstall scripts for native addons. Co-Authored-By: Claude Opus 4.6 --- apps/api/Dockerfile | 6 +++- .../api/src/terminal/terminal.service.spec.ts | 4 ++- apps/api/src/terminal/terminal.service.ts | 32 ++++++++++++++++--- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index ff7a477..f05c802 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -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 diff --git a/apps/api/src/terminal/terminal.service.spec.ts b/apps/api/src/terminal/terminal.service.spec.ts index 8a93dcf..5545405 100644 --- a/apps/api/src/terminal/terminal.service.spec.ts +++ b/apps/api/src/terminal/terminal.service.spec.ts @@ -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(); }); diff --git a/apps/api/src/terminal/terminal.service.ts b/apps/api/src/terminal/terminal.service.ts index 8025572..fbd28c9 100644 --- a/apps/api/src/terminal/terminal.service.ts +++ b/apps/api/src/terminal/terminal.service.ts @@ -13,11 +13,16 @@ * - 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 { 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. +type NodePtyModule = typeof import("node-pty"); +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 +36,7 @@ const DEFAULT_ROWS = 24; export interface TerminalSession { sessionId: string; workspaceId: string; - pty: pty.IPty; + pty: import("node-pty").IPty; name?: string; createdAt: Date; } @@ -53,7 +58,7 @@ export interface SessionCreatedResult { } @Injectable() -export class TerminalService { +export class TerminalService implements OnModuleInit { private readonly logger = new Logger(TerminalService.name); /** @@ -66,13 +71,30 @@ export class TerminalService { */ private readonly workspaceSessions = new Map>(); + async onModuleInit(): Promise { + 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;