前端WebTerminal解决方案

1.需求背景

后台管理类项目中经常有一种需求场景—在前端页面使用终端工具操作远程服务器。伴随此功能的往往还有内置命令,快捷键操作等附加需求。由于浏览器安全策略,想在前端项目中通过 js 或者是其他的方式唤起操作系统的终端程序,基本上没有这种可能。前端可以实现一个伪终端输入窗口,配合后端服务支持实现类似终端程序的效果。

2.技术调研

插件 问题 推荐指数
Xterm 主流方案 ⭐️⭐️⭐️⭐️

3.技术实现

  1. 依赖安装
1
pnpm add @xterm/xterm @xterm/addon-fit
  1. 实例化 terminal
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<template>
<div class="terminal-containet">
<div ref="terminalDom" class="terminal"></div>
</div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from "vue";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";

onMounted(() => {
initTerminal();
});

let terminalDom = ref(null);
let terminalInstance = ref(null);
let fitAddon = new FitAddon();

//初始化Terminal工具
const initTerminal = () => {
terminalInstance.value = new Terminal({
fontSize: 14,
fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace",
theme: {
background: "rgba(0,0,0,0)",
},
cursorBlink: true,
cursorStyle: "underline",
scrollback: 100,
tabStopWidth: 4,
});
terminalInstance.value.open(terminalDom.value);
terminalInstance.value.loadAddon(fitAddon); //加载自适应宽高插件
terminalInstance.value.focus(); //聚焦
};
</script>
  1. 页面视口变化监听:此步骤用于给后端服务创建的终端插件实时同步宽高,保证两端输入输出效果一致
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup>
import { useResizeObserver } from "@vueuse/core";
//terminal尺寸变化执行事件
const terminalSizeChange = () => {
fitAddon.fit();
const { cols, rows } = terminalInstance.value;
send(
JSON.stringify({
event: "changeSize",
data: { cols, rows },
})
);
};
//监听DOM元素尺寸变化重置Terminal
useResizeObserver(terminalDom, terminalSizeChange);
</script>
  1. websocket 信息交互:此步骤用于将键盘事件通过 websocket 同步到后端终端,模拟实时输入效果。与此同时前端根据后端返回的数据,实时将信息流赋值到 terminal 终端,显示真正的命令行交互效果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<script setup>
...
import { useWebSocket} from "@vueuse/core";
...

const { send, close } = useWebSocket("ws://127.0.0.1:8888", {
onMessage: (ws, e) => {
handleOnMessage(e); //onMessage事件监听
},
autoReconnect: {
retries: 5,
delay: 1000,
onFailed() {
console.error("websocket链接失败");
},
},
});

//监听键盘输入发送websocket通知
const handleOnData = (e) => {
send(JSON.stringify({ event: "keyInput", data: e }));
};
//处理websocket响应事件
const handleOnMessage = (e) => {
const { event, data } = JSON.parse(e.data);
// if (data == "\r") terminalInstance.value.writeln("");
if (event == "streamOutput") {
terminalInstance.value.write(data);
}
};
</script>
  1. Node 后端服务实现

    • 依赖安装
    1
    pnpm add ws ssh2 utf8
    • websocket 服务
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    const { WebSocketServer } = require("ws");
    const utf8 = require("utf8");
    const SSHClient = require("ssh2").Client;

    //websocket服务启动
    let wsIns;
    const wss = new WebSocketServer({ port: 8888 });
    console.log("websocket服务已开启:ws://127.0.0.1:8888");
    wss.on("connection", (ws) => {
    ws.sendData = (event, data) => {
    ws.send(JSON.stringify({ event, data }));
    };
    ws.on("error", console.error);
    ws.on("message", (msg) => {
    const { event, data } = JSON.parse(msg);
    if (event == "createNewServer") {
    createNewServer(data);
    }
    });
    ws.on("close", () => {
    clearTimeout(ws._closeTimeout, ws.send);
    });
    ws._closeTimeout = setTimeout(() => {
    ws.terminate();
    }, 600000);
    wsIns = ws;
    });
    • SSH 服务
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    const createNewServer = (config) => {
    const { cols, rows, host, username, password } = config;
    let ssh = new SSHClient();
    ssh
    .on("ready", function () {
    wsIns.sendData("streamOutput", "SSH CONNECTION SUCCESS\r\n");
    ssh.shell({ cols, rows }, (err, stream) => {
    if (err) {
    return wsIns.sendData(
    "streamOutput",
    "\r\n*** SSH SHELL ERROR: " + err.message + " ***\r\n"
    );
    }
    wsIns.on("message", (msg) => {
    const { event, data } = JSON.parse(msg);
    if (event == "keyInput") {
    stream.write(data);
    } else if (event == "changeSize") {
    stream.setWindow(data.rows, data.cols);
    }
    });
    stream
    .on("data", (d) => {
    wsIns.sendData(
    "streamOutput",
    utf8.decode(d.toString("binary"))
    );
    })
    .on("close", () => {
    ssh.end();
    });
    });
    })
    .on("close", function () {
    wsIns.sendData("streamOutput", "SSH CONNECTION CLOSED\r\n");
    })
    .on("error", function () {
    wsIns.sendData("streamOutput", "SSH CONNECTION ERROR\r\n");
    })
    .connect({
    host,
    port: 22,
    username,
    password,
    });
    };

4. 注意事项

  1. 前端键盘事件无需本地保存字符串后统一传递给后端,实时传输即可。中心思想,前端只展示后端返回数据。
  2. 后端服务注意 websocket 及 SSH 服务实现即可。中心思想:后端只做数据转发。

5.效果预览

image-20241021162013667