node-pty
node-pty is a native Node.js binding for starting a process inside a pseudoterminal instead of ordinary stdin and stdout pipes. That distinction makes shells, REPLs, full-screen terminal programs, color detection, cursor control, and window resizing behave as if a person opened a real terminal. It exposes output events plus write, resize, pause, resume, clear, and kill methods across Linux, macOS, and modern Windows ConPTY. It provides the process side of a terminal; a browser UI still needs a renderer such as xterm.js and a transport such as WebSocket.
node-pty is the right low-level dependency when software genuinely needs terminal semantics, and its use in VS Code is meaningful evidence. Do not pay the native-build and security costs for routine subprocess execution, and isolate any shell reachable over a network.
Use it if
- You are building an IDE, terminal emulator, browser shell, or test runner that must behave like a real TTY
- A child program changes behavior when stdout is a pipe and you need its colors, prompts, control sequences, or full-screen mode
- You need to send terminal resize events as a user changes the visible rows and columns
- You need one pseudoterminal API across Linux, macOS, and Windows ConPTY
- You only need to run a command and capture stdout: node:child_process or execa avoids a native add-on and terminal control sequences
- Your deployment cannot compile native code: the install script falls back to node-gyp, and the stable package contains no Linux prebuild directory
- You plan to expose an unrestricted shell from an internet-facing server: the README warns that children inherit the parent process permissions and recommends host isolation
- You need to use one PTY instance across Node.js worker threads: the project explicitly says node-pty is not thread safe
- You must support old Windows systems: winpty support was removed and Windows 10 version 1809, build 18309, or later is required
Setup reality
npm install node-pty looks ordinary, but this is a native add-on. Version 1.1.0 ships prebuilt binaries for macOS x64 and arm64 plus Windows x64 and arm64. Its install script runs node-gyp rebuild when a matching prebuild directory is absent, which includes Linux in the published stable tarball. Linux builders need Python, make, and a C++ toolchain; macOS builders need Xcode; Windows source builds need Python, Visual C++, the Windows SDK, and sometimes the matching Spectre-mitigated libraries. The README sets the floor at Node.js 16 or Electron 19 and ties ongoing support largely to the runtime used by VS Code. Windows requires ConPTY on Windows 10 version 1809, build 18309, or later. At runtime, choose the shell separately for Windows and Unix, pass a complete environment, and preserve SystemRoot on Windows or PowerShell can fail with error 8009001d. Output is a terminal byte stream containing ANSI control sequences, not clean command output. Wire onData to a terminal renderer, send user input back with write, propagate viewport changes with resize, and always dispose listeners and kill abandoned processes. A networked terminal also needs authentication, command authorization, session limits, idle cleanup, output backpressure, and container or user isolation because the spawned program has the same privilege level as the Node process.
Patterns
Spawn the platform's interactive shellspawn-user-shell
import os from 'node:os';
import * as pty from 'node-pty';
const shell = os.platform() === 'win32'
? (process.env.COMSPEC ?? 'powershell.exe')
: (process.env.SHELL ?? 'bash');
const term = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: process.cwd(),
env: process.env,
});Choose the executable per platform and keep SystemRoot in the Windows environment so PowerShell can initialize correctly.
Stream PTY output to the current terminalstream-terminal-output
const outputSubscription = term.onData((data) => {
process.stdout.write(data);
});
term.onExit(() => outputSubscription.dispose());Data contains terminal control sequences and partial chunks; do not treat each event as a complete line.
Forward local keyboard inputsend-terminal-input
process.stdin.setRawMode?.(true);
process.stdin.resume();
process.stdin.on('data', (chunk) => term.write(chunk));Raw mode forwards control keys immediately; restore your stdin mode during shutdown if this runs inside a larger application.
Start one interactive programrun-interactive-command
const repl = pty.spawn(process.execPath, ['--interactive'], {
name: 'xterm-256color',
cols: 100,
rows: 30,
cwd: process.cwd(),
env: process.env,
});
repl.onData((data) => process.stdout.write(data));Arguments are passed as an array; on Windows a pre-escaped command-line string is also accepted, but quoting then becomes your responsibility.
Propagate viewport changesresize-terminal
function resizeTerminal(cols, rows) {
if (!Number.isInteger(cols) || !Number.isInteger(rows)) return;
if (cols < 1 || rows < 1) return;
term.resize(cols, rows);
}
resizeTerminal(120, 36);Use character columns and rows, not pixel dimensions, and validate values received from an untrusted client.
Clean up when the PTY exitsobserve-process-exit
const dataSubscription = term.onData(sendToClient);
const exitSubscription = term.onExit(({ exitCode, signal }) => {
dataSubscription.dispose();
exitSubscription.dispose();
closeClient({ exitCode, signal });
});The event provides an exit code and an optional signal; dispose listeners so reconnecting clients do not accumulate handlers.
Kill an abandoned PTY sessionterminate-session
const idleTimer = setTimeout(() => {
if (process.platform === 'win32') term.kill();
else term.kill('SIGHUP');
}, 15 * 60 * 1000);
term.onExit(() => clearTimeout(idleTimer));Passing a signal is unsupported on Windows and throws; call kill without one there.
Enable automatic XON/XOFF flow controlenable-flow-control
const term = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: 80,
rows: 24,
env: process.env,
handleFlowControl: true,
});
term.write('long-running-command\r');Flow control is experimental. With it enabled, complete XOFF and XON messages pause and resume the PTY instead of reaching the child.
Use application-specific pause messagescustomize-flow-control
const term = pty.spawn(shell, [], {
env: process.env,
handleFlowControl: true,
flowControlPause: '\x1b[PAUSE',
flowControlResume: '\x1b[RESUME',
});Custom values avoid collisions when child output may contain ordinary XON or XOFF bytes; matching occurs only when a message arrives whole.
Pause output while a client is congestedmanual-backpressure
term.onData((data) => {
const accepted = socket.write(data);
if (!accepted) term.pause();
});
socket.on('drain', () => term.resume());pause and resume control PTY reading; connect them to the transport's real backpressure signal instead of buffering without a limit.
Bridge a PTY to an authenticated socketbridge-websocket
const output = term.onData((data) => socket.send(JSON.stringify({ type: 'data', data })));
socket.on('message', (raw) => {
const message = JSON.parse(String(raw));
if (message.type === 'input') term.write(String(message.data));
if (message.type === 'resize') term.resize(message.cols, message.rows);
});
socket.on('close', () => {
output.dispose();
term.kill();
});Authenticate and authorize before spawning, validate resize bounds, cap input and output, and isolate the child from the host.
Synchronize a cleared Windows terminalclear-conpty-buffer
function clearTerminal() {
terminalRenderer.clear();
term.clear();
}
clear only affects node-pty's internal buffer on Windows ConPTY; it is a no-op on other platforms.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| execa | npm | Choose it for running commands with captured output, cancellation, and errors when the child does not need a real terminal |
| cross-spawn | npm | Choose it for portable child_process spawning and Windows command resolution without PTY behavior |
| ssh2 | npm | Choose it when the terminal session belongs on a remote SSH server and you need SSH authentication and channel management |