うさラボ

お勉強と備忘録

XtermでWebコンソールを作ってみた

作ったもの

AWSクラウドシェルや、監視装置などにたまについているWebコンソールを作ってみました。

コードはほとんどChatGPTに生成してもらったので、理解度がかなり浅く拡張が効かないのが難点・・

バックエンド

バックエンドはFastAPIにしました。

main.py

import os
import pty
import asyncio
import signal
import logging
from fastapi import FastAPI, WebSocket
from starlette.websockets import WebSocketDisconnect

# Uvicorn のログを使う
logger = logging.getLogger("uvicorn.error")

# FastAPI インスタンス
app = FastAPI()

# 作業ディレクトリ(仮想端末の起動先)
BASE_DIR = "/home/usaen/test"


@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()

    # 子プロセス(bash)と仮想端末作成
    pid, fd = pty.fork()

    if pid == 0:
        # ===== 子プロセス:bash 起動側 =====
        try:
            os.chdir(BASE_DIR)
        except Exception as e:
            print(f"chdir failed: {e}")
        os.execvp("bash", ["bash"])  # 子プロセスを bash に置き換え
    else:
        # ===== 親プロセス:中継側 =====
        loop = asyncio.get_running_loop()

        # 表示(PTY)をWebsocketに返す
        # PTY → WebSocket
        async def read_from_pty():
            try:
                while True:
                    data = await loop.run_in_executor(None, os.read, fd, 1024)
                    await websocket.send_text(data.decode(errors="ignore"))
            except (asyncio.CancelledError, Exception):
                logger.info("[PTY] reader stopped")

        # Websocket(ユーザーインプット)をPTYに渡す
        # WebSocket → PTY
        async def read_from_ws():
            try:
                while True:
                    data = await websocket.receive_text()
                    os.write(fd, data.encode())
            except WebSocketDisconnect:
                logger.info("[WS] client disconnected")
                # ★ WebSocketが切れたら子プロセスを殺す
                try:
                    os.kill(pid, signal.SIGKILL)
                    logger.info(f"[WS] killed child process pid={pid}")
                except Exception as e:
                    logger.warning(f"[WS] failed to kill pid={pid}: {e}")
            except asyncio.CancelledError:
                logger.info("[WS] reader cancelled")

        try:
            await asyncio.gather(read_from_pty(), read_from_ws())
        except asyncio.CancelledError:
            logger.info("[Main] websocket handler cancelled")
        finally:
            try:
                # ① PTYファイルディスクリプタを閉じる
                os.close(fd)
                logger.info("[Main] PTY closed")

                # ② 子プロセス(bash)に SIGKILL を送る
                os.kill(pid, signal.SIGKILL)
                logger.info(f"[Main] Sent SIGKILL to bash (pid={pid})")

                # ③ 終了を待ってゾンビ化を防止
                _, status = os.waitpid(pid, 0)
                logger.info(f"[Main] bash exited with status {status}")
            except Exception as e:
                logger.warning(f"[Main] cleanup failed: {e}")

起動
uv run uvicorn main:app --reload --host 0.0.0.0 --port 3002

フロントエンド

React + Vite + TailwindCSS + shadcnで構築
毎回shadcnのインストールで手こずります。。この手のインストールはChatGPTに聞くより公式ドキュメント見ながらやるほうが早いなって思います。

ui.shadcn.com

ButtonとCardのコンポーネントを追加しています。

terminal.tsx

import React, { useRef, useEffect, useCallback, useState } from 'react';
import { Terminal } from 'xterm'; // 仮想ターミナル本体
import { FitAddon } from 'xterm-addon-fit'; // サイズ自動調整用アドオン
import 'xterm/css/xterm.css'; // xterm のスタイル読み込み
import { Button } from "./components/ui/button"
import {
    Card,
    CardContent,
} from "./components/ui/card"

const TerminalView: React.FC = () => {
    // DOM 要素の参照(ターミナル表示先)
    const containerRef = useRef<HTMLDivElement>(null);

    // ターミナル、アドオン、WebSocket を useRef で保持
    const terminalRef = useRef<Terminal | null>(null);
    const fitAddon = useRef<FitAddon | null>(null);
    const socketRef = useRef<WebSocket | null>(null);

    // 接続状態のトラッキング(ボタンの制御用)
    const [connected, setConnected] = useState(false);

    // ========== セッションリセット用関数 ==========
    const cleanup = useCallback(() => {
        // 旧ターミナルの破棄
        terminalRef.current?.dispose();
        terminalRef.current = null;

        // WebSocket のイベント解除と明示的 close
        if (socketRef.current) {
            socketRef.current.onmessage = null;
            socketRef.current.onclose = null;
            socketRef.current.onerror = null;
            socketRef.current.close();
            socketRef.current = null;
        }

        // FitAddon は参照削除だけでOK
        fitAddon.current = null;

        // 状態リセット
        setConnected(false);
    }, []);

    // ========== ターミナル+WebSocket 初期化 ==========
    const initializeTerminal = useCallback(() => {
        cleanup(); // 古いセッションが残っていれば掃除

        // xterm 初期化
        const term = new Terminal({ cursorBlink: true });
        const fit = new FitAddon();

        term.loadAddon(fit);
        term.open(containerRef.current!); // DOM に描画

        // ★ 描画タイミングで fit() 実行しないと描画が壊れることがある
        requestAnimationFrame(() => {
            try {
                fit.fit(); // 親要素サイズに合わせて調整
            } catch (e) {
                console.warn('fit() failed:', e);
            }
        });

        // WebSocket 接続(ポート番号・エンドポイントに注意)
        const socket = new WebSocket(`ws://${window.location.hostname}:3002/ws`);

        // 接続成功時の処理
        socket.onopen = () => {
            setConnected(true);
        };

        // サーバ → クライアント
        socket.onmessage = (event) => {
            term.write(event.data);
        };

        // エラー時のログ
        socket.onerror = (err) => {
            console.error("WebSocket error", err);
        };

        // 切断された場合の通知
        socket.onclose = (e) => {
            console.warn('WebSocket Closed', e);
            term.write('\r\n[Connection closed !]\r\n');
            setConnected(false);
        };

        // クライアント → サーバ
        term.onData((data: string) => {
            if (socket.readyState === WebSocket.OPEN) {
                socket.send(data);
            }
        });

        // 参照登録
        terminalRef.current = term;
        fitAddon.current = fit;
        socketRef.current = socket;
    }, [cleanup]);

    // ========== コンポーネント破棄時のクリーンアップ ==========
    useEffect(() => {
        return () => cleanup(); // unmount 時にもセッション破棄
    }, [cleanup]);

    // ========== 描画部分 ==========
    return (
        <div className='px-5 py-1' style={{ height: '52vh', display: 'flex', flexDirection: 'column' }}>
            <Card>
                <CardContent className='p-0.5'>
                    <div className="p-1 flex gap-2">
                        {/* 接続ボタン */}
                        <Button onClick={initializeTerminal} disabled={connected}>
                            接続
                        </Button>
                        {/* 切断ボタン */}
                        <Button onClick={cleanup} disabled={!connected}>
                            切断
                        </Button>
                    </div>
                    {/* ターミナルの描画領域 */}
                    <div
                        className='p-3'
                        ref={containerRef}
                        style={{
                            flexGrow: 1,
                        }}
                    />
                </CardContent>
            </Card>
        </div>
    );
};

export default TerminalView;

main.tsx

import TerminalView from './terminal';

function App() {
  return (
    <>
      <div className='py-5'>
        <h1 className="scroll-m-20 text-center text-4xl font-extrabold tracking-tight text-balance">
          Webコンソール
        </h1>
        <TerminalView />
      </div>
    </>
  );
}

export default App;

起動
npm run dev