229 lines
7.8 KiB
Markdown
229 lines
7.8 KiB
Markdown
# 🖥️ 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
|