7.8 KiB
🖥️ Terminal Implementation Reference
Component: TerminalView
Location: zlh-portal (Next.js frontend)
Stack: xterm.js (@xterm/xterm) + WebSocket + React
Last Updated: February 22, 2026
Overview
ZeroLagHub uses a reusable TerminalView React component for both game server consoles and dev container consoles. It wraps xterm.js with a WebSocket transport, exposes a minimal prop API, and handles its own lifecycle cleanup.
The component is intentionally display-agnostic — it doesn't know whether it's showing a Minecraft console or a dev shell. The caller owns the WebSocket URL and decides whether input is enabled.
Component Source
"use client";
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
type TerminalViewProps = {
welcomeMessage?: string;
wsUrl?: string;
enableInput?: boolean;
onSend?: ((send: ((data: string) => void) | null) => void) | null;
onStatus?: ((status: "idle" | "connecting" | "open" | "closed" | "error") => void) | null;
};
export default function TerminalView({
welcomeMessage = "Terminal ready. Awaiting connection...",
wsUrl,
enableInput = false,
onSend = null,
onStatus = null,
}: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const onSendRef = useRef<typeof onSend>(onSend);
const onStatusRef = useRef<typeof onStatus>(onStatus);
useEffect(() => {
onSendRef.current = onSend;
}, [onSend]);
useEffect(() => {
onStatusRef.current = onStatus;
}, [onStatus]);
useEffect(() => {
if (!containerRef.current) return;
const terminal = new Terminal({
cursorBlink: false,
scrollback: 2000,
fontSize: 13,
disableStdin: !enableInput,
});
const fitAddon = new FitAddon();
const inputDisposable = enableInput
? terminal.onData((data) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(data);
}
})
: null;
terminal.loadAddon(fitAddon);
terminal.open(containerRef.current);
fitAddon.fit();
terminal.writeln(welcomeMessage);
terminalRef.current = terminal;
const handleResize = () => fitAddon.fit();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
if (inputDisposable) inputDisposable.dispose();
if (socketRef.current) {
socketRef.current.close();
socketRef.current = null;
}
if (onSendRef.current) onSendRef.current(null);
terminalRef.current?.dispose();
terminalRef.current = null;
};
}, [welcomeMessage, enableInput]);
useEffect(() => {
if (!wsUrl || !terminalRef.current || socketRef.current) {
if (!wsUrl && onSendRef.current) onSendRef.current(null);
if (!wsUrl && onStatusRef.current) onStatusRef.current("idle");
return;
}
if (onStatusRef.current) onStatusRef.current("connecting");
terminalRef.current.writeln("Connecting to stream...");
const socket = new WebSocket(wsUrl);
socketRef.current = socket;
const send = (data: string) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(data);
}
};
if (onSendRef.current) onSendRef.current(send);
socket.addEventListener("message", (event) => {
// Write raw chunks — do not add extra newlines per message
terminalRef.current?.write(String(event.data));
});
socket.addEventListener("open", () => {
terminalRef.current?.writeln("Connected.");
if (onStatusRef.current) onStatusRef.current("open");
});
socket.addEventListener("close", () => {
terminalRef.current?.writeln("Connection closed.");
if (onSendRef.current) onSendRef.current(null);
if (onStatusRef.current) onStatusRef.current("closed");
});
socket.addEventListener("error", () => {
terminalRef.current?.writeln("Connection error.");
if (onStatusRef.current) onStatusRef.current("error");
});
}, [wsUrl]);
return <div ref={containerRef} className="h-full w-full" />;
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
welcomeMessage |
string |
"Terminal ready. Awaiting connection..." |
First line written to terminal on mount |
wsUrl |
string | undefined |
— | WebSocket URL to connect to. Changing this URL reconnects. |
enableInput |
boolean |
false |
If true, keystrokes are sent over the WebSocket. Set false for read-only consoles (game servers). |
onSend |
((send) => void) | null |
null |
Callback that receives a send(data) function when connected, or null when disconnected. Lets the parent send commands programmatically. |
onStatus |
((status) => void) | null |
null |
Callback fired on connection state changes: idle | connecting | open | closed | error |
Architecture Notes
Two separate useEffect blocks — intentional
The terminal mount/unmount lifecycle and the WebSocket lifecycle are deliberately separated:
- Effect 1 (
[welcomeMessage, enableInput]): Creates and destroys the xterm.js terminal instance. Runs once on mount in practice. - Effect 2 (
[wsUrl]): Creates and connects the WebSocket. Re-runs whenwsUrlchanges, enabling seamless reconnection by simply changing the URL prop.
This prevents the WebSocket from being torn down and recreated every time an unrelated prop changes.
Callback refs pattern (onSendRef, onStatusRef)
onSend and onStatus are stored in refs and kept current via their own useEffect syncs. This avoids stale closure bugs inside the WebSocket event handlers — the handlers always call the latest version of the callback without needing to re-register when the parent re-renders.
Raw write, not writeln
The message handler uses terminal.write() not terminal.writeln(). The PTY stream already contains its own newlines and carriage returns. Adding \n per message would double-space all output.
disableStdin vs enableInput prop
xterm.js disableStdin prevents the terminal from capturing keyboard events at all. For game server consoles where the user should only observe (not interact), enableInput={false} sets this, keeping the terminal purely read-only. Dev container consoles pass enableInput={true}.
Usage Examples
Read-only game console
<TerminalView
wsUrl={`wss://api.zerolaghub.com/ws/game/${vmid}/console`}
welcomeMessage="Game console — read only"
enableInput={false}
onStatus={(s) => setConnectionStatus(s)}
/>
Interactive dev container shell
const [sendFn, setSendFn] = useState<((data: string) => void) | null>(null);
<TerminalView
wsUrl={`wss://api.zerolaghub.com/ws/dev/${vmid}/console`}
welcomeMessage="Dev shell"
enableInput={true}
onSend={(fn) => setSendFn(() => fn)}
onStatus={(s) => setConnectionStatus(s)}
/>
// Send a command programmatically
<button onClick={() => sendFn?.("ls -la\n")}>List files</button>
Dependencies
"@xterm/xterm": "^5.x",
"@xterm/addon-fit": "^0.10.x"
The CSS import (@xterm/xterm/css/xterm.css) is required for correct rendering. Without it, the terminal renders but loses scroll behavior and cursor styling.
Related
- Agent PTY implementation:
zlh-agent— server process is forked with a PTY attached, raw output streams over the WebSocket - WebSocket proxy: API (
zlh-api) proxies the WebSocket connection from frontend to agent — frontend never connects to the agent directly (DEC-008) - Console stability work:
zlh-grind/SESSION_LOG.md— Feb 7-8 session covers single-writer pattern and PTY ownership fixes