이전 글에서는 프론트와 SSH 중개 서버를 각각 구현해봤습니다.
이번 시간에는 프론트에 좀 더 기능을 붙혀 중개 서버와 연동하여 실제 UI 상에서 동작하는 웹 터미널을 완성해보겠습니다!
서버와 socket.io 통신을 하기 위해 프런트 기준 3개의 Listen Event와 2개의 Emit Event를 정의해보겠습니다. (서버 입장에서는 3개의 Emit Event와 2개의 Listen Event곘죠?)
Listen Event
Emit Event
총 5개의 이벤트를 Typescript의 인터페이스로 정의해보겠습니다.
interface IListenEvents {
'update': (data: string) => void;
'error': () => void;
'eof': () => void;
}
interface IEmitEvents {
connectTerminal: () => void;
type: (data: string) => void;
}
이렇게 인터페이스를 정의하여
SocketIOClient의 상속 타입으로 지정하면 타입 힌트를 받을 수 있습니다!
이제 해당 이벤트를 바탕으로 소켓 클라이언트의 코드를 마무리해볼까요?
// WebTerminalClient.ts
...
constructor(listeners: IListeners) {
super('/ssh');
const {updateEventListener, disconnectListener, eofListener} = listeners;
this.addConnectEventListener();
this.addUpdateEventListener(updateEventListener);
this.addDisconnectEventListener(disconnectListener);
this.addEofEventListener(eofListener);
this.client.io.on('reconnect_failed', () => {
console.log('reconnect_failed');
});
}
private addConnectEventListener() {
this.client.on('connect', () => {
console.log('socket.io 연결됨');
});
}
private addUpdateEventListener(cb: (data: string) => void) {
this.client.on('update', cb);
}
private addDisconnectEventListener(cb: () => void) {
this.client.on('disconnect', cb);
}
private addEofEventListener(cb: () => void) {
this.client.on('eof', cb);
}
emitConnectTerminalEvent() {
this.client.emit('connectTerminal');
}
emitTypeEvent(data: string) {
this.client.emit('type', data);
}
...
생성자에서는 클라이언트를 생성할 때 인자로 넘어온 리스너 함수들을 각 이벤트에 등록하는 과정을 거치게 됩니다.
또한 이벤트를 Emit할 수 있도록 추상화 함수를 만들어 주도록 합니다.
추후 해당 클라이언트 객체를 사용하는 터미널 객체에서 해당 함수들을 사용하게 될 예정입니다.
전체 코드
//WebTerminalClient.ts
import SocketIOClient from "./SocketIOClient.ts";
interface IListenEvents {
'update': (data: string) => void;
'error': () => void;
'eof': () => void;
}
interface IEmitEvents {
connectTerminal: () => void;
type: (data: string) => void;
}
interface IListeners {
updateEventListener: (data: string) => void;
disconnectListener: () => void;
eofListener: () => void;
}
class WebTerminalClient extends SocketIOClient<IListenEvents, IEmitEvents> {
constructor(listeners: IListeners) {
super('/ssh');
const {updateEventListener, disconnectListener, eofListener} = listeners;
this.addConnectEventListener();
this.addUpdateEventListener(updateEventListener);
this.addDisconnectEventListener(disconnectListener);
this.addEofEventListener(eofListener);
this.client.io.on('reconnect_failed', () => {
console.log('reconnect_failed');
});
}
private addConnectEventListener() {
this.client.on('connect', () => {
console.log('socket.io 연결됨');
});
}
private addUpdateEventListener(cb: (data: string) => void) {
this.client.on('update', cb);
}
private addDisconnectEventListener(cb: () => void) {
this.client.on('disconnect', cb);
}
private addEofEventListener(cb: () => void) {
this.client.on('eof', cb);
}
emitConnectTerminalEvent() {
this.client.emit('connectTerminal');
}
emitTypeEvent(data: string) {
this.client.emit('type', data);
}
disconnect() {
this.client.disconnect();
}
connect() {
this.client.connect();
}
isConnected(): boolean {
return this.client.connected;
}
}
export default WebTerminalClient;
Termianl 클래스 완성하기이제 Terminal 클래스의 세부 동작들을 구현해 보겠습니다.
Terminal 클래스는 사용자의 입력을 받아서 이전 단계에서 만든 WebTermianlClient를 이용하여 서버에게 이벤트들을 전달하고 반대로 서버에서 전달된 데이터들을 어떻게 처리해야하는지를 정의해야 합니다.
이를 위한 함수들을 작성해보겠습니다.
// termianl.ts
private onUpdate(data: string) {
this.writeOnTerminal(data);
}
private onDisconnected() {
this.writeOnTerminal(
'\x1b[1G\n\nSession disconnected.\n' +
"\x1b[1GPress 'Enter' to reconnect.\n\n\x1b[1G",
);
}
private onEof() {
this.writeOnTerminal('\x1b[1G\n\nTerminal terminated.\n\x1b[1G');
}
private writeOnTerminal(data: string) {
this.xterm.write(data);
}
private onData(data: string) {
this.webTerminalClient.emitTypeEvent(data);
}
private onKey(event: { key: string; domEvent: KeyboardEvent }) {
if (this.webTerminalClient.isConnected()) return;
const {
domEvent: { key },
} = event;
if (key === 'Enter') {
this.webTerminalClient.connect();
this.connectToTerminal();
}
}
private connectToTerminal() {
this.webTerminalClient.emitConnectTerminalEvent();
this.writeOnTerminal('\x1b[1GConnecting to terminal...\n\x1b[1G');
}
onUpdate: 서버에서 연결된 터미널의 데이터가 넘어올 때, 웹 U에 해당 데이터를 뿌려줌onEof: 터미널이 종료되었을 때, 종료되었다는 프롬프트를 띄움writeOnTerminal: xterm 객체를 이용하여 프롬프트를 띄우는 함수onData: 사용자가 터미널에 데이터를 입력했을 때, 해당 데이터를 서버에게 전달onKey: 특정키 입력을 처리하는 함수로, 터미널이 종료되어있을 때 터미널에 재접속을 시도함connectToTerminal: 터미널에 접속을 시도하는 함수이제 Terminal 클래스에 WebTerminalClient를 멤버 변수로 추가하고, 생성자에서 각 이벤트에 대한 리스너 함수를 등록해줍니다.
..
// terminal.ts
private readonly webTerminalClient: WebTerminalClient;
constructor() {
this.xterm = new XTerminal(
{
cursorBlink: true,
scrollSensitivity: 2,
theme: {
background: "#222"
}
}
);
this.xterm.loadAddon(this.fitAddon);
this.webTerminalClient = new WebTerminalClient({
updateEventListener: (data: string) => this.onUpdate(data),
disconnectListener: () => this.onDisconnected(),
eofListener: () => this.onEof(),
});
this.xterm.onData((data: string) => this.onData(data));
this.xterm.onKey((event) => this.onKey(event));
this.connectToTerminal();
}
...
이전 단계에서 WebTerminalClient를 생성할 때 인자로 리스너 함수를 전달해준다고 했었죠? 실제로 클래스를 생성하면서 정의한 함수들을 인자로 넘겨주고 있습니다.
또한 xterm 객체에도 리스너 함수들을 등록해줍니다.
전체 코드
// termianl.ts
import {FitAddon} from '@xterm/addon-fit';
import {Terminal as XTerminal} from '@xterm/xterm';
import WebTerminalClient from "./WebTerminalClient.ts";
export default class Terminal {
private readonly xterm: XTerminal
private readonly fitAddon = new FitAddon();
private readonly webTerminalClient: WebTerminalClient;
constructor() {
this.xterm = new XTerminal(
{
cursorBlink: true,
scrollSensitivity: 2,
theme: {
background: "#222"
}
}
);
this.xterm.loadAddon(this.fitAddon);
this.webTerminalClient = new WebTerminalClient({
updateEventListener: (data: string) => this.onUpdate(data),
disconnectListener: () => this.onDisconnected(),
eofListener: () => this.onEof(),
});
this.xterm.onData((data: string) => this.onData(data));
this.xterm.onKey((event) => this.onKey(event));
this.connectToTerminal();
}
open(container: HTMLDivElement) {
this.xterm.open(container);
this.xterm.focus();
}
dispose() {
this.xterm.dispose();
}
fit() {
this.fitAddon.fit()
}
private onUpdate(data: string) {
this.writeOnTerminal(data);
}
private onDisconnected() {
this.writeOnTerminal(
'\x1b[1G\n\nSession disconnected.\n' +
"\x1b[1GPress 'Enter' to reconnect.\n\n\x1b[1G",
);
}
private onEof() {
this.writeOnTerminal('\x1b[1G\n\nTerminal terminated.\n\x1b[1G');
}
private writeOnTerminal(data: string) {
this.xterm.write(data);
}
private onData(data:ㅅ string) {
this.webTerminalClient.emitTypeEvent(data);
}
private onKey(event: { key: string; domEvent: KeyboardEvent }) {
if (this.webTerminalClient.isConnected()) return;
const {
domEvent: { key },
} = event;
if (key === 'Enter') {
this.webTerminalClient.connect();
this.connectToTerminal();
}
}
private connectToTerminal() {
this.webTerminalClient.emitConnectTerminalEvent();
this.writeOnTerminal('\x1b[1GConnecting to terminal...\n\x1b[1G');
}
}
프론트 측은 이것으로 완성입니다!
이제 서버랑 연동을 해볼까요? 서버와 프론트를 각각 실행해주세요.

이런식으로 입력한 프롬프트가 떴다면 잘 따라오신겁니다!!! 축하드려요!

이번 글을 마지막으로 웹 터미널을 만들어 보았습니다.
인턴으로 다니던 회사에서 만들면서 재미있었던 경험이 떠오르네요.
저처럼 중개 서버를 사용하는 방법도 있지만 Node 환경에서 SSH를 접속하는 방법도 있다고 하니,
관심 있는 분들은 추가적으로 더 공부하셔서 만들어보는 것도 재미있겠네요.