docs: add TerminalView component reference with architecture notes
This commit is contained in:
parent
3db9416c60
commit
705f9c04e3
228
Frontend/TerminalView_Component.md
Normal file
228
Frontend/TerminalView_Component.md
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user