This guide walks you end‑to‑end through creating a Node.js server that runs an interactive CLI/TUI (Text User Interface) app as its backend, and streams the app’s output to clients over WebSockets. We’ll cover:
- ✅ Architecture & data flow
- ✅ PTY (pseudo‑terminal): what it is, why you need it, and how we use it here
- ✅ ANSI escape codes on TUIs: what they are and how to handle them
- ✅ Using
node-ptyto run the TUI correctly - ✅ Using
strip-ansi(or regex) to get clean text - ✅ WebSocket server & client examples
- ✅ Filtering TUI “noise” (spinners, banners, prompts)
- ✅ Reliability, performance, and production tips
- ✅ Testing with
wscatand browser clients
Use case: You want to treat a CLI TUI (e.g.,
qwen) as a “model endpoint” and expose it as a web service. Clients connect, send prompts, and receive model output—without needing a terminal.
Goal: Bridge an interactive CLI app to web clients.
[Browser/Client] <--WS--> [Node.js Server] <--> [CLI TUI in PTY]
input ---------------------------------> stdin (PTY)
stdout/stderr (ANSI) <------------------ output (PTY)
- WebSocket (WS): bi‑directional transport between browser/clients and your server.
- PTY (pseudo-terminal): lets the CLI believe it’s running in a real terminal, so it behaves correctly (colors, prompts, streaming).
- ANSI: terminal control codes embedded in CLI output (colors, cursor moves, spinners). You may want to render them (terminal UI) or strip them (plain text).
Many interactive CLIs behave differently when connected to a terminal versus plain pipes:
- When attached to a terminal, programs detect
isatty(stdin/stdout)and enable line editing, color, progress UI, interactive prompts, etc. - When run via
child_process.spawnwith pipes (no TTY), some REPL‑like tools exit early, buffer weirdly, or suppress rich UI.
A PTY provides a virtual terminal device the process attaches to, so the app behaves exactly as it does in your shell.
- On Linux/macOS it uses
forkpty/openptyunder the hood. - On Windows it uses ConPTY (Windows 10+) or winpty on older systems.
- In Node.js, you get a PTY object with:
onData(callback)for outputwrite(data)to send inputresize(cols, rows)to adjust terminal sizekill()to terminate
We’ll use node-pty to spawn the CLI TUI and stream its output in real time.
ANSI escape sequences start with ESC (ASCII 27, \x1b) and control terminals:
- Colors: e.g.,
\x1b[31m(red),\x1b[0m(reset) - Cursor movement:
\x1b[2K(erase line),\x1b[1A(move cursor up) - Spinners & UI redraws: lines prefixed with braille chars
⠋⠙⠹…plus updates - OSC (Operating System Command): window titles
\x1b]2;title\x07 - Box/Rule lines: UI decorations (
─────────────────…)
Options when serving ANSI output:
- Render ANSI on the client using a terminal emulator (e.g., xterm.js) → users see the rich TUI like in a shell.
- Strip ANSI on the server (e.g., with
strip-ansi) → users receive plain text only (good for chat logs, JSON APIs).
mkdir tui-bridge && cd tui-bridge
npm init -y
# Core deps
npm i express ws node-pty
# Optional: for plain text mode (strip ANSI control codes)
npm i strip-ansi
# Handy for CLI testing
npm i -g wscatIf you prefer ES modules, add
"type": "module"topackage.json.
If you keep CommonJS, use.cjsandrequire().
This version streams raw ANSI. Ideal if your client renders ANSI (e.g., xterm.js).
server-pty.cjs (CommonJS)
const { WebSocketServer } = require('ws');
const http = require('http');
const express = require('express');
const pty = require('node-pty');
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
app.get('/health', (_req, res) => res.json({ ok: true, pid: process.pid }));
wss.on('connection', ws => {
// Spawn your TUI (use absolute path if Node’s PATH differs from your shell)
const p = pty.spawn('qwen', [], {
name: 'xterm-color',
cols: 100,
rows: 30,
cwd: process.cwd(),
env: process.env,
});
// PTY -> WS (raw ANSI)
p.onData(data => {
ws.send(JSON.stringify({ type: 'stdout', data }));
});
// Lifecycle
p.onExit(({ exitCode }) => {
ws.close(1000, `exit:${exitCode ?? 0}`);
});
// WS -> PTY
ws.on('message', msg => {
p.write(msg.toString() + '\r'); // send input + Enter
});
ws.on('close', () => {
try { p.kill('SIGTERM'); } catch {}
});
});
server.listen(3444, () => {
console.log('WS+PTY server on ws://localhost:3444 (ANSI preserved)');
});Client idea: use xterm.js in the browser to render ANSI beautifully.
If you want clean text (no spinners/colors), strip control codes before sending.
Important: Recent strip-ansi versions are ESM-only. In CommonJS, access the default export:
const stripAnsi = require('strip-ansi').default;
// or use dynamic import:
// const { default: stripAnsi } = await import('strip-ansi');server-clean.cjs (CommonJS)
const { WebSocketServer } = require('ws');
const http = require('http');
const express = require('express');
const pty = require('node-pty');
const stripAnsi = require('strip-ansi').default;
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
app.get('/health', (_req, res) => res.json({ ok: true, pid: process.pid }));
// Filtering helpers (heuristics for Qwen-like TUI)
const SPINNER_PREFIXES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
const isSpinnerLine = (line) => SPINNER_PREFIXES.some(p => line.startsWith(p + ' '));
const isRulerLine = (line) => line.trim().startsWith('────────────────');
const isPromptHint = (line) => /Type your message or @path\/to\/file/i.test(line);
const isWorkspaceFooter = (line) => /\bno sandbox\b.*\bcoder-model\b/i.test(line);
const isStatusVerb = (line) =>
/(Initializing|Counting electrons|Defragmenting memories|Just a moment|finding the right meme)/i.test(line);
const isAssistantLine = (line) => line.trim().startsWith('✦ ');
const isDecorative = (line) =>
isSpinnerLine(line) ||
isRulerLine(line) ||
isPromptHint(line) ||
isWorkspaceFooter(line) ||
isStatusVerb(line) ||
/^\s*$/.test(line);
const normalize = (chunk) => stripAnsi(chunk).replace(/\r/g, '');
wss.on('connection', ws => {
const p = pty.spawn('qwen', [], {
name: 'xterm-color',
cols: 100,
rows: 30,
cwd: process.cwd(),
env: process.env,
});
let buffer = [];
let flushTimer = null;
const flush = () => {
if (!buffer.length) return;
const payload = buffer.join('\n');
buffer = [];
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'stdout', data: payload }));
}
};
// Small delay to coalesce spinner redraw frames
const scheduleFlush = () => {
if (flushTimer) return;
flushTimer = setTimeout(() => {
flushTimer = null;
flush();
}, 150);
};
p.onData(data => {
const text = normalize(data);
const lines = text.split('\n');
for (const line of lines) {
if (isDecorative(line)) continue;
if (isAssistantLine(line)) {
const msg = line.replace(/^✦\s+/, '').trimEnd();
if (msg) buffer.push(msg);
continue;
}
const cleaned = line.trimEnd();
if (cleaned.length) buffer.push(cleaned);
}
scheduleFlush();
});
p.onExit(({ exitCode }) => {
flush();
ws.close(1000, `exit:${exitCode ?? 0}`);
});
ws.on('message', msg => p.write(msg.toString() + '\r'));
ws.on('close', () => {
try { p.kill('SIGTERM'); } catch {}
if (flushTimer) clearTimeout(flushTimer);
});
});
server.listen(3444, () => {
console.log('WS+PTY (ANSI stripped + filtered) on ws://localhost:3444');
});Prefer no dependency? Use a regex fallback:
const ANSI_REGEX = /\x1B\[[0-9;?]*[A-Za-z]|\x1B\][^\x07]*\x07|\x1B[()#][0-9A-Za-z]|[\x00-\x1F\x7F]/g; const stripAnsi = s => s.replace(ANSI_REGEX, '');
node server-clean.cjs
wscat -c ws://localhost:3444
> hello
# Expect clean messages like:
# {"type":"stdout","data":"Hello! How can I assist you today?"}// client.js (ESM)
import WebSocket from 'ws';
const ws = new WebSocket('ws://localhost:3444');
ws.on('open', () => {
ws.send('Hello!');
});
ws.on('message', data => {
const msg = JSON.parse(data.toString());
console.log(`[${msg.type}]`, msg.data);
});
ws.on('close', (c, r) => console.log('closed', c, r.toString()));
ws.on('error', err => console.error('error', err));If you used the ANSI preserved server, render rich TUI in the browser:
If you used the ANSI stripped server, show clean text:
TUIs redraw frequently (spinners, progress, prompt lines). To get meaningful content:
- Strip ANSI → removes colors/cursor moves; still leaves status lines.
- Filter lines by heuristics:
- Drop spinner frames (prefix
⠋⠙⠹…). - Drop decorative rulers (
─────────────────…). - Drop workspace footer (“no sandbox”, “coder-model (99%)”).
- Drop prompt hints (“Type your message…”).
- Keep lines starting with
✦or other content prefixes; remove the marker and emit as assistant text.
- Drop spinner frames (prefix
- Coalesce/buffer output for 100–250ms to group partials into one message (reduces flicker).
- Debounce (e.g., 600ms of idle time) to emit only the final answer for each turn.
Every TUI differs; start with the heuristics above, then refine for your CLI’s exact format.
- Absolute path to CLI: Node’s
PATHmay differ from your interactive shell (e.g.,/usr/local/bin). Usewhich qwen, then:const p = pty.spawn('/usr/local/bin/qwen', [], { /* ... */ });
- Backpressure: WS can congest—monitor
ws.bufferedAmount. Consider batching output or pausing UI updates (not trivial with PTY; buffering is easiest). - Lifecycle:
- Kill the child on WS close:
p.kill('SIGTERM'); optionally hard kill after a delay. - Map exit codes to clean WS closure reasons.
- Kill the child on WS close:
- Health checks: add
/healthroute to verify server status. - Logging: log stderr, exit codes, durations. Add request IDs for tracing.
- Security:
- Validate inputs; your server sends user text into a CLI. Avoid commands that could write files or run shell code unless you sandbox.
- Rate limit / auth for multi‑tenant usage.
- Concurrency:
- One PTY per WS connection (isolated sessions).
- For heavy workloads, use a job queue (BullMQ/Redis) and stream results back; or provision multiple worker processes.
- Windows/macOS/Linux:
node-ptyuses ConPTY on modern Windows; ensure a recent OS.- Some CLIs behave differently; tune
cols/rowsto avoid wrapping issues.
strip-ansiis ESM-only now:- CommonJS:
const stripAnsi = require('strip-ansi').default; - ESM:
import stripAnsi from 'strip-ansi'; - CommonJS + dynamic import:
(async () => { const { default: stripAnsi } = await import('strip-ansi'); })();
- CommonJS:
If you see:
TypeError: stripAnsi is not a function
you’re likely using require('strip-ansi') without .default.
- Start your server (clean or ANSI‑preserved).
node server-clean.cjs # or: node server-pty.cjs - Connect with
wscat:wscat -c ws://localhost:3444 > hello - Observe output: clean text vs raw ANSI.
- If no output or
exit:1, check:- PATH mismatch (use absolute path)
- CLI arguments required?
- Does CLI crash without a TTY? (You’re using PTY here, so you’re good.)
- Server-Sent Events (SSE): simple one‑way streaming (server → client). Good for browser clients that don’t need to send data during streaming.
- JSON mode (if your CLI supports it): Some CLIs can output structured JSON. Prefer this where available—it avoids ANSI and TUI parsing entirely.
- Use
node-ptyto run the CLI in a real terminal context. - Decide whether to preserve ANSI (rich terminal experience) or strip ANSI (plain text).
- Add filters to remove TUI noise and extract meaningful content.
- Stream over WebSockets, batch/throttle to avoid flicker.
- Harden for production: path handling, backpressure, lifecycle, security, and logging.
If you share:
- Which CLI & typical prompts,
- Whether you want rich TUI (xterm.js) or clean chat,
- Your deployment OS (Linux/Windows/macOS),
- Any special requirements (auth, rate limit, job queue),
I’ll draft a production‑ready repo layout for you—server, clients (terminal and web), Dockerfile, and tests—so you can plug in your CLI and ship.