docs: add TerminalView component reference with architecture notes

This commit is contained in:
jester 2026-02-22 20:29:07 +00:00
parent 3db9416c60
commit 705f9c04e3

View File

@ -0,0 +1,228 @@
# 🖥️ 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
```typescript
"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 when `wsUrl` changes, 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
```tsx
<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
```tsx
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
```json
"@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