Web Terminal

160 阅读2分钟

WebSocket 全双工通信

HTTP 升级版, 握手阶段也采用Http 协议, 握手时通过 HTTP 服务器

Request Headers {
    ...
        Connection: keep-alive, Upgrade, 
        Upgrade: websocket //升级websocket服务
        Sec-WebSocket-Key: hash 串表示的key
        Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
        Sec-WebSocket-Version: 13
    ...
}
Response Headers {
    ...
        HTTP/1.1 101 switching Protocols
        Upgrade: websocket
        Connection: Upgrade
        Sec-WebSocket-Key: hash 串表示的key
    ...
}

WebShell Terminal 最佳实践

前端环境 React 17 + sockjs-client + Xterm@4.18.0 + ahooks 全家桶

注意: 从 Xtermjs 5 开始 使用 canvas 画布渲染需要搭配 xterm-addon-fit 使用

Xterm 全家桶

  • Xterm
  • Xterm-addon-attach
  • xterm-addon-fit
  • xterm-addon-web-links
import { Terminal } from 'xterm';
import React, { useEffect, useState, useRef } from 'react';
import { FitAddon } from 'xterm-addon-fit';
import { webLinksAddon } from 'xterm-addon-web-links';
import PropTypes from 'prop-types';
import SockJs from 'sockjs-client';
import { useDebounceEffect } from 'ahooks';
import { notification, Layout, message} from 'ant-design'

import './index.less';
import 'xterm/css/xterm.css';

const { Header } = Layout;

const WebTerminal = props => {
    const { shellType } = props;
    const terminal = useRef();
    const prefix = useRef();
    const terminalWindow = useRef(null);
    const dockerSocket = useRef();
    
    useEffect(() => {
        initTerminal();
        /**
        * 业务代码初始化
        */
        return () => {
            //资源回收
            if (dockerSocket.current) dockerSocket.current = null;
            terminal.current.dispose();
            if (terminal.current) terminal.current = null;
        }
    }, []);
    //使用ONKey - 更安全的监听键盘输入事件
    useEffect(() => {
        if(terminal && shellType == 'terminal') {
            terminal.current.onKey(e => {
                const {key, domEvent} = e;
                const { altGraphKey, metaKey } = domEvent;
                const printable = !(altGraphKey || metaKey);
                if(printable) dockerSocket.current.send(JSON.stringify({'op':'stdin', 'Data': key}));
            })
        }
    });
    //使用 onData,直接捕获输入数据, 喜欢用JQ 等同于JQ的on('data', () => {})
    /*
        useDebounceEffect(() => {
            if(terminal && shellType == 'terminal') {
            terminal.current.onData(data => {
                let text= data.replace(/[\u4e00-\u9fa5]+/g, '');
                  dockerSocket.current.send(JSON.stringify({'op':'stdin', 'Data': text}));  
            })
}
        })
    */
    const initTerminal = () => {
        // new Fit Size 对象
        const fitAddon = new FitAddon();
        // 风格设置
        terminal.current = new Termianl({
            rendererType: 'canvas', //采用 canvas 画布
            cursorBlink: true, //显示光标
            cursorStyle: "block", //设置背景色
            disableStdin: shellType == 'log', //设置是否可输入
            convertEol:true, //LF 和 CRLF
            theme: {
                foreground: '#ffffff',
                background: '#000000',
                cursor: 'help',
                lineHeight: 18
            } //定制主题
        });
        terminal.current.open(document.getElementById('terminal-container'));
        terminal.current.loadAddon(fitAddon); //注册屏幕自适应组件
        terminal.current.loadAddon(new WebLinksAddon());
        //屏幕自适应
        fitAddon.fit();
        //监听浏览器窗口进行全屏自适应
        window.addEventListener('resize', () => {
            onTerminalResize();
            fitAddon.fit();
        });
        if (!terminal.current._initialized) terminal.current._initialized = true;
        terminal.current.prompt = () => terminal.current.write(`\r\n${prefix.current}`);
        terminal.current.writeln(`Entering the container ... ... `);
        terminal.current.writeln('');
        //自定义处理按键
 //基于https
 terminal.current.attachCustomKeyEventHandler(event => {
        const { code, type, ctrlkey } = event;
        if(!navigator.clipboard) {
            message.warning('当前为非安全网络,请升级https');
            if (code === "KeyV" && type === 'keydown' && ctrlkey) {
                return false;
            }
            return true;
        } else {
            if (code === 'keyC' && type === 'keydown' && ctrlkey) {
                navigator.clipboard.writeText(terminal.current.getSelection());
                return false;
            }
            if(code === 'keyV' && type === 'keydown' && ctrlKey) {
                navigator.clipboard.readText().then(text => dockerSocket.current.send(JSON.stringify({ 'Op': 'stdin', 'Data': text })));
                return false;
            }
            return false;
        } 
        return true;
  });
  /*基于http*/
 /* terminal.current.attachCustomKeyEventHandler(event => {
    const { code, type, ctrlkey } = event;
    if(code === 'keyV' && type === 'keydown' && ctrlKey) {
        return false;
    }
    return true;
  });
  */
  
  if(shellType == 'termianl') {
      getWebSocketSessionId().then(() => {
          terminal.current.writeln('\x1b[1;1;36m 终端连接成功的话 。。。\x1b[0m');
      }).catch(() => {
       terminal.current.writeln('\x1b[1;1;31m 终端连接失败的话 。。。\x1b[0m');
      });
  }
}

const getWebSocketSessionId = () => new Promise((resolve, reject) => {
   (//....后端请求接口).response(res => {
       if(res.data) {
           const { sessionId } = res.data;
           dockerSocket.current = new SockJs(ws服务(hosturl, sessionId));
           dockerSocket.current.onopen = onConnectOpen.bind(this, res.data);
           dockerSocket.current.onmessage = onConnectionMessage.bind(this);
           dockerSocket.current.onclose = onConnectionClose.bind(this);
           dockerSocket.current.onerror = onConnectionError.bind(this);
           resolve();
        } else {
            reject();
       }
    }, err => {
        reject();
    }, ...);
})

const onConnectOpen = (data) => {
    dockerSocket.current.send(JSON.stringify({ 'Op': 'stdin', 'Data': data })));
    onTerminalResize();
    terminal.current.focus();
}

const onTerminalResize = () => {
    const fullWidth = terminalWindow.current?.nativeElement?.parentElement?.clientWidth??默认值;
    const fullHeight = terminalWindow.current?.nativeElement?.parentElement?.clientHeight??默认值;
    let width = terminalWindow.current?.offsetParent?.clientWidth??fullWidth;
    let height = terminalWindow.current?.offsetParent?.clientHeight??fullHeight;
    let cols = (width - terminal.current?._core?.viewport?.scrollBarWidth -15) / terminal.current?._core?._renderService?.dimensions?.actualCellWidth;
    let rows = height / terminal.current?._core?._renderService?.dimensions?.actualCellHeight;
    let data = {
        Cols: cols && cols != 'NaN' ? parseInt(cols) : 默认值;
        Rows: rows && rows != 'NaN' ? parseInt(rows) : 默认值; 
    }
    dockerSocket.current.send(JSON.stringify({ 'Op': 'resize', 'Cols': data.Cols, 'Rows': data.Rows })));
}

const onConnectionMessage = (event) => {
    try{
        const msg = JSON.parse(event.data);
        prefix.current = msg.Data;
        switch(msg['Op']) {
            case 'stdout':
                if(~msg['Data'].indexOf('\r')) {
                    onTerminalResize();
                }
                terminal.current.write(msg['Data']);
                break;
            case 'stdin':
            case 'resize':
            default: break;
        }
    }catch(e){
        //错误通知
    }
}

const onConnectionError = () => {
}

const onConnectionClose = (event) => {
}

    return (
        <div class="terminal">
        <Layout>
            <Header>
                <div id="terminal-container" classNmae='c-webTerminal' ref={terminalWindow}/>
            </Header>
        </Layout>
        </div>
    )
}

WebTerminal.defaultProps = {
}

WebTerminal.propTypes = {
}

export default WebTerminal;