• logo

      SeolMyeongTang

  • xterm.js과 node-pty로 웹 터미널을 만들어보겠습니다. 근데 이제 rollup을 곁들인 - 백엔드편

    2022년 10월 18일

    이번에는 백엔드 부분까지 작성하여 터미널 다운 웹 터미널을 만들어 볼 예정입니다. 진정한 웹 터미널을 만들기 위해서는 앞서 다룬 프론트엔드의 xterm.js 뿐만 아니라 bashcmd와 같은 쉘이 필요합니다. xterm.js과 쉘을 연결시키기 위해 node-pty 모듈을 활용할 것입니다. node-pty는 Microsoft에서 관리하고 있는 node 모듈로, pty 프로세스를 만들어주는 모듈입니다. 웹 터미널 백엔드 부분은 프론트엔드 부분보다 간결하게 작성할 수 있습니다.

    TYPESCRIPT
    // server/src/Pty.ts
    
    import os from "os";
    import { spawn, IPty } from "node-pty";
    
    class Pty {
      ptyProcess: IPty;
      shell: string;
    
      constructor() {
        this.shell = os.platform() === "win32" ? "cmd.exe" : "bash";
    
        this.init();
      }
    
      init() {
        this.ptyProcess = spawn(this.shell, [], {
          name: "xterm-color",
          cwd: process.env.HOME,
          env: process.env as { [key: string]: string },
        });
      }
    }
    
    export default Pty;
    

    node-pty는 리눅스의 forkpty(3)(그 중 opentty(), fork(2) 부분) 명령어를 NodeJS에서 쓸 수 있도록 만든 모듈입니다. 위 예제 코드의 init() 함수를 잠시 살펴보면 node-ptyspawn 함수로 this.shell 명령어를 실행(fork) 합니다. 이 같은 부분은 node-pty 모듈의 unixTerminal.ts 코드를 참고하면 찾을 수 있습니다. node-pty 모듈 코드를 읽어보는 것도 흥미로우니, 관심있으신 분은 한 번 찾아보는 걸 추천드립니다.

    TYPESCRIPT
    export class UnixTerminal extends Terminal {
    	...
    	constructor(file?: string, args?: ArgvOrCommandLine, opt?: IPtyForkOptions) {
    		...
    		// fork
        const term = pty.fork(file, args, parsedEnv, cwd, this._cols, this._rows, uid, gid, (encoding === 'utf8'), onexit);
    		...
    	}
    	...
    }
    

    저희는 쉘이 필요하므로 OS가 윈도우일 경우는 cmd를 그 외는 bash를 넣어줍니다. xterm.js를 위한 쉘을 만들었지만 어떻게 이 둘 사이의 통신을 하나요? 실시간으로 양방향 통신하기 위해 socket 통신을 사용합니다.

    Socket 통신을 하기 위해서 프론트엔드, 백엔드 두 쪽 모두 통신할 수 있는 (이벤트)을 만들어야합니다. 길을 만들기 위해 통신 과정을 생각 해봅시다.

    1. 사용자가 키보드를 입력한다.
    2. xterm.js가 키보드 이벤트를 감지하고 백엔드로 보낸다.
    3. 백엔드는 xterm.js으로 부터 받은 문자를 받는다.
    4. pty가 받은 문자를 해석하고 결과 값을 xterm.js으로 던진다.
    5. xterm.js은 pty로 부터 결과 값을 받고 웹 브라우저에 출력한다.
    웹 터미널 socket 통신 과정
    웹 터미널 socket 통신 과정

    2, 3번 과정에서 xterm.js에서 문자 데이터를 보내고(emit) 백엔드에서 데이터를 받고(on) 4, 5번에서 백엔드에서 데이터를 보내고(emit) xterm.js에서 받는(on) 설정이 필요합니다. 이를 코드로 작성하면 다음과 같습니다.

    TYPESCRIPT
    // 프론트엔드에서의 socket 이벤트 설정
    // frontend/src/config/SocketConfig.ts 코드 일부분
    
    init(term: xterm) {
            this.socket.on('output', (message) => {
            term.write(message);
        });
    }
    
    execute(command: string) {
        this.socket.emit('input', command);
    }
    
    // 백엔드에서의 socket 이벤트 설정
    // server/src/Socket.ts 코드 일부분
    
    io.on('connection', (socket) => {
        console.log('Socket Connected: ', socket.id);
    
        this.pty = new Pty(socket);
    
        socket.on('disconnect', () => {
            console.log('Socket Disconnected: ', socket.id);
        });
        socket.on('input', (input: string) => {
            this.pty.write(input);
        });
    });
    
    // server/src/Pty.ts 코드 일부분
    
    send(data: string) {
        this.socket.emit('output', data);
    }
    

    둘 사이 데이터를 주고 받는 socket 이벤트를 만들었지만, 그 전에 socket 연결 초기화하는 부분도 작성해봅시다. Socket 연결하는 코드는 프론트엔드 쪽에 작성하고 컴포넌트 props로 넘깁니다. 웹 터미널 라이브러리 코드도 이에 따라 수정합니다.

    TYPESCRIPT
    // frontend/src/config/SocketConfig.ts 코드 일부분
    
    class SocketConfig {
        socket: SocketIOClient;
        url: string;
    
        constructor() {
            this.url =
                process.env.REACT_APP_SOCKET_URL === undefined
                    ? 'http://localhost:3001/'
                    : process.env.REACT_APP_SOCKET_URL;
    
            this.socket = io(this.url, {
                transports: ['websocket'],
            });
        }
        ...
    }
    
    TYPESCRIPT
    // frontend/src/App.tsx 코드
    
    const App = () => {
      const socketConfig = new SocketConfig();
    
      return (
        <div className="App">
          <WebTerminal socket={socketConfig} />
        </div>
      );
    };
    
    // frontend/lib/src/index.d.ts
    import { SocketConfig } from "./constants/SocketConfig";
    
    declare const WebTerminal: (param: {
      socket: SocketConfig;
    }) => React.ReactElement;
    
    export default WebTerminal;
    
    TYPESCRIPT
    // frontend/lib/src/WebTerminal.tsx 코드 일부분
    export default class Terminal {
        ...
        _socket: SocketConfig;
    
        constructor(socket: SocketConfig) {
            this.term = new xterm(this._options);
            ...
    
            this._socket = socket;
            this._socket.init(this.term);
            ...
        }
        ...
    }
    

    마침내 socket 연결 설정을 하고 이벤트를 만들었으니, 웹 터미널 껍데기에 불과하였던 xterm.js을 가지고 실제 터미널처럼 사용할 수 있게 되었습니다. 프론트엔드와 백엔드 코드를 실행시키고 확인해보면, 명령어 실행에 대한 결과물을 받을 수 있습니다.

    웹 터미널 socket 연결 후, 명령어 실행 결과
    웹 터미널 socket 연결 후, 명령어 실행 결과

    이렇게 작성한 웹 터미널을 로컬에서만 아닌 외부 접속이 가능하게 하려면 AWS와 같은 서버를 통해 배포를 진행하면 됩니다. 배포를 위한 docker를 작성했으니 관심있는 분은 참고하시면 좋을 것 같습니다.

    간단하게 기본적인 터미널 기능을 하는 웹 터미널을 만들어 보았습니다. 이번 글을 통해 웹 터미널이 ‘이런 느낌으로 동작하는구나!’라는 것만 파악했다면 이 글의 목적은 달성한 것 같습니다. 웹 터미널을 자신의 입맛대로 꾸미는 것도 재미있게 구성할 수 있으니 나만의 웹 터미널을 한 번 만들어보면 좋을 것 같습니다. 끝으로 웹 터미널에 대한 코드를 정리한 GitHub 주소를 남기면서 부족한 글과 코드 봐주셔서 감사합니다 :)

    GitHub