A POSIX-like shell written in C, with a web-based terminal GUI.
Browser (xterm.js) ←──WebSocket──→ Node.js server ←──PTY──→ minishell (C)
| Feature | Notes |
|---|---|
| Pipelines | ls | grep .c | wc -l — up to 64 segments |
| I/O redirection | > >> < — works inside pipelines too |
| Background jobs | sleep 10 & — shell reaps them at each prompt |
| Variable expansion | $VAR ${VAR} $? $$ |
| Tilde expansion | ~/foo → /home/user/foo |
| Command history | 500-entry ring buffer, deduplicated |
| Built-ins | cd exit [n] history export unset |
minishell/
├── minishell.c ← the shell (C)
├── Makefile
└── gui/
├── package.json
├── src/
│ └── server.js ← Node.js WebSocket + PTY bridge
└── public/
├── index.html
├── style.css
└── terminal.js
make # produces ./minishellcd gui
npm installnode-pty compiles a native addon — you need python3, make, and a C++
compiler. On Ubuntu/Debian: sudo apt install build-essential python3.
On macOS Xcode Command Line Tools are enough.
# from the gui/ directory
# Use the absolute path to minishell (or it won't spawn correctly in the PTY)
# Note: Use WSL Node.js directly to avoid Windows npm issues
SHELL_BIN={pwd}../minishell /usr/bin/node src/server.jsOpen http://localhost:3000 in your browser. You should see a terminal running your minishell.
User types a key
│
▼
xterm.js (browser)
term.onData(data)
│ JSON { type:"input", data:"ls\r" }
▼
WebSocket ──────────────────────────────────────────────────────► server.js
│
│ shell.write(data)
▼
node-pty
(PTY master)
│
─ ─ ─ ─ ─│─ ─ ─ ─ ─
kernel line discipline
─ ─ ─ ─ ─│─ ─ ─ ─ ─
│
▼
minishell (C)
reads from stdin,
writes to stdout
│
─ ─ ─ ─ ─│─ ─ ─ ─ ─
│
node-pty
shell.onData(out)
│
JSON { type:"output", data }
WebSocket ◄──────────────────────────────────────────────────────────┘
│
▼
xterm.js
term.write(data)
│
▼
Canvas renders ANSI output
A pseudoterminal (PTY) is a kernel-level abstraction that makes a process believe it is talking to a real terminal, even though it is not. It has two sides:
- Master — held by
node-pty. Reads output, writes input. - Slave — given to
minishellas its stdin/stdout/stderr. The shell sees it as a normal terminal device (it can checkisatty()).
The kernel's line discipline sits between them. It handles:
- Echoing characters back to the screen
- Translating
\rto\r\n(carriage-return newline) - Sending
SIGINTwhen Ctrl-C is pressed - Sending
SIGTSTPwhen Ctrl-Z is pressed SIGWINCHwhen the window is resized
This is why minishell needs no changes at all — it just reads from stdin and writes to stdout, exactly as it would in a real terminal.
When the browser window changes size:
ResizeObserverfiresfitAddon.fit()recalculatesterm.colsandterm.rowsto fill the DOM elementterminal.jssends{ type:"resize", cols, rows }over the WebSocketserver.jscallsshell.resize(cols, rows)on the PTY- The kernel sends
SIGWINCHtominishell - The shell's
readline(or your own logic if you handle it) redraws
Each browser tab that opens ws://localhost:3000/terminal gets its own
WebSocket connection → its own pty.spawn() call → its own independent
shell process. No shared state.
# Serve over HTTPS (required for wss://)
# Set PORT and SHELL_BIN, put behind nginx/caddy for TLS.
PORT=8080 SHELL_BIN=/usr/local/bin/minishell node src/server.js