xterm.js과 node-pty로 웹 터미널을 만들어보겠습니다. 근데 이제 rollup을 곁들인 - 백엔드편
2022년 10월 18일이번에는 백엔드 부분까지 작성하여 터미널 다운 웹 터미널을 만들어 볼 예정입니다. 진정한 웹 터미널을 만들기 위해서는 앞서 다룬 프론트엔드의 xterm.js
뿐만 아니라 bash
나 cmd
와 같은 쉘이 필요합니다. 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-pty
의 spawn
함수로 this.shell
명령어를 실행(fork) 합니다. 이 같은 부분은 node-pty
모듈의 unixTerminal.ts
코드를 참고하면 찾을 수 있습니다. node-pty
모듈 코드를 읽어보는 것도 흥미로우니, 관심있으신 분은 한 번 찾아보는 걸 추천드립니다.
TYPESCRIPTexport 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 통신을 하기 위해서 프론트엔드, 백엔드 두 쪽 모두 통신할 수 있는 길(이벤트)을 만들어야합니다. 길을 만들기 위해 통신 과정을 생각 해봅시다.
- 사용자가 키보드를 입력한다.
- xterm.js가 키보드 이벤트를 감지하고 백엔드로 보낸다.
- 백엔드는 xterm.js으로 부터 받은 문자를 받는다.
- pty가 받은 문자를 해석하고 결과 값을 xterm.js으로 던진다.
- xterm.js은 pty로 부터 결과 값을 받고 웹 브라우저에 출력한다.
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
을 가지고 실제 터미널처럼 사용할 수 있게 되었습니다. 프론트엔드와 백엔드 코드를 실행시키고 확인해보면, 명령어 실행에 대한 결과물을 받을 수 있습니다.

이렇게 작성한 웹 터미널을 로컬에서만 아닌 외부 접속이 가능하게 하려면 AWS와 같은 서버를 통해 배포를 진행하면 됩니다. 배포를 위한 docker를 작성했으니 관심있는 분은 참고하시면 좋을 것 같습니다.
간단하게 기본적인 터미널 기능을 하는 웹 터미널을 만들어 보았습니다. 이번 글을 통해 웹 터미널이 ‘이런 느낌으로 동작하는구나!’라는 것만 파악했다면 이 글의 목적은 달성한 것 같습니다. 웹 터미널을 자신의 입맛대로 꾸미는 것도 재미있게 구성할 수 있으니 나만의 웹 터미널을 한 번 만들어보면 좋을 것 같습니다. 끝으로 웹 터미널에 대한 코드를 정리한 GitHub 주소를 남기면서 부족한 글과 코드 봐주셔서 감사합니다 :)