某些情况下,使用终端命令可以更方便的处理一些任务,例如
- mkdir jpg && mv *.jpg ./jpg:批量移动 jpg 文件
- rm -rf *.jpg:批量删除文件
- find、grep 搜索命令
开发
主要使用下面两个工具
-
WebSocket
-
- @xterm/addon-fit:自适应父元素高宽
WebSocket:作为客户端与服务端的链接基础,方便 Xterm.js 将每次键盘输入的状态传输给 node 服务。
Xterm.js:为客户端的终端基础框架。提供一些方法供开发者对接服务。
node-pty:为对接本地终端的工具。例如 bash、zsh、sh 或者 win 的 powershell.exe。由微软提供,vscode 内置的终端使用。
使用上面的几个工具组合起来,就能实现一个"web 终端"。
安装依赖
css
pnpm i xterm node-pty @xterm/addon-fit
Next.js 实现 WebSocket 对接 node-pty
创建一个初始化 node-pty 的文件方法
explorer-manager/src/pty/main.mjs
javascript
import pty from 'node-pty'
import fs from 'fs'
const zsh = fs.existsSync('/bin/zsh') && 'zsh'
const bash = fs.existsSync('/bin/bash') && 'bash'
const sh = fs.existsSync('/bin/sh') && 'sh'
const [shell] = [zsh, bash, sh].filter(Boolean)
console.log({ shell })
/**
*
* @returns {import('node-pty').IPty}
*/
export const initPty = () => {
return pty.spawn(shell, [], {
name: 'xterm-color',
cols: 80,
rows: 30,
cwd: process.env.HOME,
env: process.env,
})
}
通过路径判断是否存在 zsh、bash、sh 文件,并将它作为 pty.spawn 的对接对象。并将当前运行的环境变量注入。
使用 socket.io 搭建一个 WebSocket 服务
pages/api/terminal-socket.ts
typescript
import { Server as NetServer } from 'http'
import { NextApiRequest } from 'next'
import { Server as ServerIO } from 'socket.io'
import { NextApiResponseServerIO } from '@/pages/api/types'
import { initPty } from '@/explorer-manager/src/pty/main.mjs'
export const config = {
api: {
bodyParser: false,
},
}
const ioHandler = (req: NextApiRequest, res: NextApiResponseServerIO) => {
if (!res.socket.server.io) {
const path = '/api/terminal-socket'
const http_server: NetServer = res.socket.server as any
const io = new ServerIO(http_server, {
path: path,
addTrailingSlash: false,
})
io.on('connection', (socket) => {
// socket.broadcast.emit('userServerConnection')
const pty_process = initPty()
socket.on('cmd', (msg) => {
pty_process.write(msg)
})
pty_process.onData((res: string) => {
// process.stdout.write(res)
socket.emit('cmd-res', res)
})
socket.on('disconnect', () => {
pty_process.kill()
console.log('A user disconnected', socket.id)
// socket.broadcast.emit('userServerDisconnection', socket.id)
})
})
res.socket.server.io = io
}
res.end()
}
export default ioHandler
代码很简单,就是使用 WebSocket 作为桥梁,将客户端提交的信息传输给 node-pty 服务,node-pty 返回的信息再返回给客户端。
Next.js 使用 antd 的 Drawer 抽屉搭建 xterm 界面
explorer/src/components/terminal/terminal-context.tsx
创建抽屉组件上下文文件
typescript
'use client'
import createCtx from '@/lib/create-ctx'
import React from 'react'
import dynamic from 'next/dynamic'
const TerminalDrawer = dynamic(() => import('@/components/terminal/terminal-drawer'), { ssr: false })
export const TerminalContext = createCtx<string>('')
export const TerminalProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<TerminalContext.ContextProvider value={''}>
{children}
<TerminalDrawer />
</TerminalContext.ContextProvider>
)
}
这里使用 dynamic 方法异步加载组件。保证终端抽屉弹窗只在浏览器中渲染。在服务端渲染 xterm 组件会报错。
创建终端抽屉组件
explorer/src/components/terminal/terminal-drawer.tsx
typescript
'use client'
import React, { useEffect, useRef } from 'react'
import { Drawer } from 'antd'
import { TerminalContext } from '@/components/terminal/terminal-context'
import { Terminal } from 'xterm'
import 'xterm/css/xterm.css'
import { io, Socket } from 'socket.io-client'
import { debounce } from 'lodash'
import styled from 'styled-components'
import { useSize } from 'ahooks'
const TerminalItemStyle = styled.div`
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
`
const proposeDimensions = (terminal_dom_ref: HTMLDivElement | null) => {
if (terminal_dom_ref) {
const { clientWidth: width, clientHeight: height } = terminal_dom_ref
const line_height = 18
const font_width = 9
return {
rows: Math.floor(height / line_height),
cols: Math.floor(width / font_width),
}
}
return {
rows: 30,
cols: 80,
}
}
export const TerminalItem: React.FC = () => {
const terminal_dom_ref = useRef<HTMLDivElement>(null)
const terminal_path = TerminalContext.useStore()
const terminal_ref = useRef<Terminal>()
const socket_ref = useRef<Socket>()
// 开发环境下热更新时销毁原先的终端
terminal_ref.current?.dispose()
useEffect(() => {
const term_dom = document.getElementById('terminal')
const terminal = (terminal_ref.current = new Terminal({
...proposeDimensions(terminal_dom_ref.current),
fontSize: 15,
}))
const socket = (socket_ref.current = io({
path: '/api/terminal-socket',
addTrailingSlash: true,
}))
const updateTerminal = debounce(() => {
if (terminal_dom_ref.current) {
const { rows, cols } = proposeDimensions(terminal_dom_ref.current)
socket.emit('update-pty', { rows, cols })
terminal.resize(cols, rows)
}
}, 500)
socket.emit('init-pty', terminal_path)
socket.on('cmd-res', (msg: string) => {
terminal.write(msg)
})
term_dom && terminal.open(term_dom)
socket.on('init-pty-done', () => {})
terminal.onData((key) => {
socket.emit('cmd', key)
})
window.onresize = updateTerminal
return () => {
terminal.dispose()
socket.disconnect()
}
}, [terminal_path])
return (
<TerminalItemStyle ref={terminal_dom_ref}>
<div id="terminal" />
</TerminalItemStyle>
)
}
const TerminalDrawer: React.FC = () => {
const dispatch = TerminalContext.useDispatch()
const terminal_path = TerminalContext.useStore()
return (
<Drawer
title="终端"
open={!!terminal_path}
placement="bottom"
destroyOnClose={true}
height="50vh"
onClose={() => dispatch('')}
styles={{ body: { padding: '10px' } }}
footer={false}
>
<TerminalItem />
</Drawer>
)
}
export default TerminalDrawer
由于是在抽屉弹窗内。addon-fit 组件无法正常的工作,cols 与 rows 不太正常。
遂通过 ref 获取组件的 width 与 height。重新获计算获取 cols 与 rows。并且在窗口大小发生改变时,同步更新 node-pty 的 cols 与 rows。避免前后端显示格式不一致。
xterm.js 键盘输入问题
当直接对接"终端服务"时,无需自己完成键盘输入事件的操作。当键盘按下时将当前事件的值通过 WebSocket 或者 fetch 传递给对应服务即可。
typescript
import { io } from 'socket.io-client'
...
useEffect(() => {
...
const socket = io({ path: '/api/terminal-socket', addTrailingSlash: false })
socket.on('cmd-res', (msg: string) => {
console.log(msg)
terminal.write(msg)
})
terminal.onData((key) => {
console.log({ key })
s_io.emit('cmd', key)
})
return () => {
socket.disconnect()
}
...
}, [])
...
或直接使用 @xterm/addon-attach
插件完成上面 WebSocket 的对接流程。
如果不使用"终端服务"时则需要自己通过 onData 或 onKey 回调处理键盘按钮事件,并记录输入内容。当按下回车时主动调用提交方法。
当使用下面的方法完成 onKey 可完成普通的按键输入反馈。但是按下键盘的"上/下/左/右"时,光标会夸过一行,在整个输入区域内移动,并不会像正常在本地使用终端那样的效果。
ini
terminal.onKey((e) => {
const ev = e.domEvent
const code = e.domEvent.code
const printable = !ev.altKey && !ev.ctrlKey && !ev.metaKey
if (code === 'Enter') {
prompt()
} else if (code === 'Backspace') {
// back 删除的情况
// @ts-ignore
if (terminal._core.buffer.x > 2) {
curr_line = curr_line.slice(0, curr_line.length - 1)
terminal.write('\b \b')
}
} else if (printable) {
curr_line += e.key
terminal.write(e.key)
}
console.log(1, 'print', e.key, code)
})
查阅大量的文档发现,可通过禁用"上/下/左/右"按钮事件反馈,绕过该功能。
csharp
terminal.attachCustomKeyEventHandler((event) => {
return !['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft'].includes(event.code)
})
或者通过大量的本地逻辑代码去控制各个事件、光标位置。需要梳理与了解整一套"终端"的运行方案。还有文案作色等等。
效果
到此就完成了 Next.js WebSocket + Xterm.js + node-pty 实现的 "web 终端"功能。