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

// 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 모듈 코드를 읽어보는 것도 흥미로우니, 관심있으신 분은 한 번 찾아보는 걸 추천드립니다.

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) 설정이 필요합니다. 이를 코드로 작성하면 다음과 같습니다.

// 프론트엔드에서의 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로 넘깁니다. 웹 터미널 라이브러리 코드도 이에 따라 수정합니다.