用 xterm.js 实现一个简易的 web-terminal !

39,334 阅读5分钟

前言

大家新年好呀~ 因为工作比较忙,有一段时间没更新了(其实就是懒),一直没想好写啥,直到最近工作中遇到了个需要内嵌 网页终端(web-terminal) 的需求,踩了不少坑,终于整明白了大概,想着写篇文章回馈下社区,于是乎说干就干,走起~

xterm.js 初探

知道需要做 web-terminal ,第一件事先网上调研一下具体需要的技术,最后发现 xterm.js 为大多数 web-terminal 的解决方案,大名鼎鼎的 vscode 也在用,看来可靠性还是有所保证的。 于是乎,我兴高采烈的敲进官网demo,瞅了一眼,好家伙看起来挺简单啊,只需要安装一下,初始化实例就行了,如下:

npm install xterm
<!doctype html>
  <html>
    <head>
      <link rel="stylesheet" href="node_modules/xterm/css/xterm.css" />
      <script src="node_modules/xterm/lib/xterm.js"></script>
    </head>
    <body>
      <div id="terminal"></div>
      <script>
        var term = new Terminal();
        term.open(document.getElementById('terminal'));
        term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
      </script>
    </body>
  </html>

因为我们项目的基于 React 的,所以我准备用 create-react-app 写个 demo 试试,一顿操作之后,把官网的例子拷进 demo 里,然后运行一下,看看效果。 随后页面出来了终端的样式,如下: 正准备输写字符看看效果,好家伙。。居然无法输入,我一度怀疑是我 demo 复制错了,仔细比对,发现确实没错啊?? 然后我找了找文档,发现输入还需要调用 api 才行,感情官网的例子居然还不能直接运行的,也是第一次见。 调整了下代码

  term.open(document.getElementById("terminal"));
  term.write("Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ");
+ term.onData((val) => {
+     term.write(val);
+   }); 

输入终于可以了,但是新的问题又来了,一删除就报错

而且一回车就光标回到最开始了,这。。我不禁陷入了沉思,再次回到文档找寻,发现了文档对 onData 的描述

contains real string data with any valid Unicode codepoint, thus the payload should be treated as UTF-16/UCS-2. For OS interaction this data should be converted to UTF-8 bytes (automatically done by node-pty). If you need legacy encoding support, see below.

原来 onData 返回的都是 UTF-16/UCS-2 编码的,要让系统认识得输出成 UTF-8 编码,怪不得我直接输入会有问题,还得自己转一下编码...这可难为我了,难道那么多按键都要做一遍解析?

幸好官方已经提出了解决方案,那就是用 node-pty 进行自动解析。使用方式也很简单,官网有如下代码

pty.onData(recv => terminal.write(recv));
terminal.onData(send => pty.write(send));

大意就是让 onData 返回的 UTF-16/UCS-2 字符串用 node-pty 解析成系统可读的 UTF-8 编码的字符串来完成输入,很明显,我们需要创建他们之间的联系,把 xterm.js 当浏览器的图形渲染界面, node-pty 当服务端监听输入的并转码的工具,通过 websocket 来关联起两边的关系,看上去可行!

使用 node-pty 解析键盘输入信号

既然知道 node-pty 可以解析,我们首先需要安装它,根据官网的描述,不同系统安装 node-pty 需要有不同的准备工作,这也可以理解,因为不同系统会有不同的差异。

Linux/Ubuntu

sudo apt install -y make python build-essential
Node.JS 10+

macOS

Xcode is needed to compile the sources, this can be installed from the App Store.

Windows

npm install --global --production windows-build-tools
Windows SDK - only the "Desktop C++ Apps" components are needed to be installed
Node.JS 10+

安装完之后,创建我们服务端的文件 server.js,果汁用 express express-ws 来搭建 node 服务 和启用 websocket 服务。

const express = require("express");
const expressWs = require("express-ws");
const app = express();
expressWs(app);
app.listen(4000, "127.0.0.1");

然后加入 node-pty 的初始代码。

const express = require("express");
const expressWs = require("express-ws");
const app = express();
expressWs(app);
const pty = require("node-pty");
const os = require("os");
const shell = os.platform() === "win32" ? "powershell.exe" : "bash";
const term = pty.spawn(shell, ["--login"], {
  name: "xterm-color",
  cols: 80,
  rows: 24,
  cwd: process.env.HOME,
  env: process.env,
});
app.ws("/socket", (ws, req) => {
  term.on("data", function (data) {
    ws.send(data);
  });
  ws.on("message", (data) => {
    term.write(data);
  });
  ws.on("close", function () {
    term.kill();
  });
});

实际场景中,还会有多个终端共同工作的场景,这样我们在服务器启动就直接初始化显然无法满足,怎么办呢? 经过一番思索.. 有了!果汁的想法是客户端初始化终端实例的时候,就初始化服务端 pty 实例,不同的终端初始化不同的 pty 实例,通过 pid 来区分,这样如果有拓展多终端的场景也可以满足。

客户端方面通过发送一个初始化的请求到服务端,服务端初始化完 pty 实例,返回当前实例的 pid ,然后客户端和服务端每次进行 websocket 交互的时候都带上 pid ,服务端通过 pid 去拿对应的 pty 实例,返回解析后的值给客户端,这样就实现了多终端的场景!

改造我们之前的代码

...
const termMap = new Map(); //存储 pty 实例,通过 pid 映射
function nodeEnvBind() {
  //绑定当前系统 node 环境
  const term = pty.spawn(shell, ["--login"], {
    name: "xterm-color",
    cols: 80,
    rows: 24,
    cwd: process.env.HOME,
    env: process.env,
  });
  termMap.set(term.pid, term);
  return term;
}
//服务端初始化
app.post("/terminal", (req, res) => {
  const term = nodeEnvBind(req);
  res.send(term.pid.toString());
  res.end();
});
app.ws("/socket/:pid", (ws, req) => {
  const pid = parseInt(req.params.pid);
  const term = termMap.get(pid);
  term.on("data", function (data) {
    ws.send(data);
  });
  ws.on("message", (data) => {
    term.write(data);
  });
  ws.on("close", function () {
    term.kill();
    termMap.delete(pid);
  });
});

客户端连接 websocket

服务端大功告成!接下来我们开始编写客户端代码,客户端需要创建 websocket 连接。

const socketURL = "ws://127.0.0.1:4000/socket/";
const ws = new WebSocket(socketURL);

光这样还不够,我们还需要获取服务端 pty 实例的 pid,来当做连接的唯一标识符,这就简单了,直接通过接口获取就行。

import axios from "axios";
...
//初始化当前系统环境,返回终端的 pid,标识当前终端的唯一性
const initSysEnv = async (term: Terminal) =>
  await axios
    .post("http://127.0.0.1:4000/terminal")
     .then((res) => res.data)
     .catch((err) => {
       throw new Error(err);
    });
const pid = await initSysEnv(term),
ws = new WebSocket(socketURL + pid);   

xterm.js 本身提供了拓展包的能力,这里我们用到它的一个扩展包 xterm-addon-attach,它可以帮我们自动和websocket进行交互,省去我们自己写了。

注意:xterm-addon-attach 需要 xterm.js v4+

import { AttachAddon } from "xterm-addon-attach";
...
attachAddon = new AttachAddon(ws);
term.loadAddon(attachAddon);

这样客户端代码也完成啦~ 让我们启动下看看。

居然跨域了。。好吧,那我们再在服务端加入防跨域代码。

// //解决跨域问题
app.all("*", function (req, res, next) {
  res.header("Access-Control-Allow-Origin", "*");
  res.header("Access-Control-Allow-Headers", "Content-Type");
  res.header("Access-Control-Allow-Methods", "*");
  next();
});

重启服务器看效果~

可看到我们已经成功运行起来了,果汁也通过 web-terminal 成功远程登陆了家里的 树莓派,看上去体验还不错哈

总结

从代码量上可以看出,实现一个 web-terminal 并不是特别困难,主要还是思路。想要源码的小伙伴,我已经把代码传到 github 上了,传送门 在这里,路过点个👍就是对我最大的支持啦,那我们下期再见~