preface

Before I introduce this article, I want to give you some background. The author is based on the company’s infrastructure
Doraemon (Doraemon)Some functional background to write this article, do not understand, interested students can go
The kangaroo cloudFind out about the treasure box below on GitHub
doraemon. Agent can be configured in Doraemon. Under the configuration details in the configuration center, you can find the corresponding nginx configuration file or other files of the host, and you can edit them here. However, the Execute shell under this function module is actually just an input box, which will create A kind of This input field is an artifact of a Web Terminal. Therefore, in order to solve this problem, we plan to make a simple version of Web Terminal to solve this problem. It is under this background that the author started the research on Web Terminal and wrote this article.

This article is named How to Build A Simple Web Terminal. It will focus on this topic and combine with Doraemon to describe it, and gradually derive the points involved and some points that the author has thought about. Of course, there are many ways to realize Web Terminal, and the author is also in the process of investigation. At the same time, this article was written ina short time and involved many points. If there are any mistakes in this article, you are welcome to point out that the author will correct them in time.

Xterm.js

First, we need a component to help us quickly set up the basic framework of Web Terminal, which is — xterm.js. So what is xterm.js? The official explanation is as follows

Xterm.js is a front-end component written in TypeScript that allows applications to bring a fully functional terminal to the user in the browser. It is used by popular projects like VS Code, Hyper, and Theia.

Because this article is mainly about building a Web Terminal, so the detailed API related to xterm.js will not be introduced, only a brief introduction to the basic API, we now only need to know that it is a component, we need to use it, If you are interested, please click on the official document to read it.

Basic API

  • Terminal

Constructor, which generates a Terminal instance

import { Terminal } from 'xterm';

const term = new Terminal();
  • OnKey, onData

Function that listens for input events on a Terminal instance

  • write

Method to write text to a Terminal instance

  • loadAddon

Method to load a plug-in on a Terminal instance

  • Attach, Fit plug-in

The fit plugin can adjust the size of Terminal to fit the parent element of Terminal

The Attach plugin provides a way to attach a terminal to a WebSocket stream. Here is an example used by the website

import { Terminal } from 'xterm';
import { AttachAddon } from 'xterm-addon-attach';

const term = new Terminal();
const socket = new WebSocket('wss://docker.example.com/containers/mycontainerid/attach/ws');
const attachAddon = new AttachAddon(socket);

// Attach the socket to term
term.loadAddon(attachAddon);

The basic use

As a component, we need to first understand its basic use, how to quickly set up the basic framework of Web Terminal. The following code uses Doraemon as an example

1. The first step is to install Xterm

npm install xterm / yarn add xterm

Use xterm to generate a Terminal instance object and mount it on a DOM element

// webTerminal.tsx import React, { useEffect, useState } from 'react' import { Terminal } from 'xterm' import { FitAddon } from 'xterm-addon-fit' import Loading from '@/components/loading' import './style.scss'; import 'xterm/css/xterm.css' const WebTerminal: React.FC = () => { const [terminal, setTerminal] = useState(null) const initTerminal = () => { const prefix = 'admin $ ' const fitAddon = new FitAddon() const terminal: any = new Terminal({ cursorBlink: }) terminal.open(document.getElementById('terminal-container')) // Terminal size matches parent terminal.loadAddon(FitAddon) fitAddon.fit() terminal.writeln('\x1b[1;1;32mwellcom to web terminal!\x1b[0m') terminal.write(prefix) setTerminal(terminal) } useEffect(() => { initTerminal() }, []) return ( <Loading> <div id="terminal-container" className='c-webTerminal__container'></div> </Loading> ) } export default WebTerminal
// style.scss
.c-webTerminal__container {
    width: 600px;
    height: 350px;
}

As shown in the figure below, we can get a Web Terminal shelf. In the above code, we need to introduce the xterm-addon-fit module, which matches the size of the generated Terminal object to the size of its parent element.

The above is the most basic use of xterm. At this time, we have the generated instance of this Terminal, but if we want to implement a Web Terminal, this is far from enough. Next, we need to gradually add bricks and tiles to it.

Input operation

When we try to input, some of you may have noticed that this shelf does not allow you to input fields, so we need to add a Terminal instance to handle the input operations. The following is an introduction to the processing of input operation. The idea of processing the input operation of Terminal is also very simple, that is, we need to add listening events to the newly generated Terminal instance. When the input operation with keyboard is captured, we will process it according to the input value corresponding to different numbers.

Due to the hurry of time comparison, we will roughly write some more common operations for processing, such as the most basic letter or number input, delete operation, cursor up and down left and right operation processing.

Basic input

The first is the most basic input operation. The code is as follows

// webTerminal.tsx ... const WebTerminal: React.FC = () => { const [terminal, SetTerminal] = useState(null) const prefix = 'admin $' let inputText = '' terminal.onKey(e => { const { key, domEvent } = e const { keyCode, altKey, altGraphKey, ctrlKey, metaKey } = domEvent const printAble = ! (altKey | | altGraphKey | | ctrlKey | | metaKey) / / prohibited related key const totalOffsetLength = inputText. + the prefix length. The length / / X // Current X offset switch(keyCode) {... default: if (! printAble) break if (totalOffsetLength >= terminal.cols) break if (currentOffsetLength >= totalOffsetLength) { terminal.write(key) inputText += key break } const cursorOffSetLength = getCursorOffsetLength(totalOffsetLength - currentOffsetLength, '\x1b[D') terminal.write('\x1b[?K' + '${key}${inputText.slice(currentoffSetLength -prefix. Length)} ') // Write key in the current coordinate Write (cursorOffsetLength) // Move the cursor that stays at the current position inputText = inputText.slice(0, currentOffsetLength) + key + inputText.slice(totalOffsetLength - currentOffsetLength) } }) } useEffect(() => { if (terminal) { onKeyAction() } }, [terminal]) ... ... } // const.ts export const TERMINAL_INPUT_KEY = { BACK: 8, // Enter: 13, // Up: 38, // Down: 40, // Left: 37, // Right: 39 // Right on the steering wheel}

Where, ‘\x1b[D’ and ‘\x1b[?K’ in the code are special characters of the terminal, represented by moving the cursor to the left one place and erasing the current cursor to the end of the line, respectively, special characters are not expanded because the author does not know much about them. Where, directly input at the end of the text will join the characters into the text. If a character is entered in a position other than trailing, the main process is as follows

Before I do that, let’s just say that currentoffSetLength, which is terminal._core.buffer.x, it increases from 0 as we go from left to right, it increases from 0 as we go from right to left, it increases from 1, it decreases, it decreases, it decreases, it decreases to 0, Use to mark the current cursor position

Suppose that the input character has two characters and the cursor is in the third bit. The following steps occur:

1. Move the cursor to the second place and press the keyboard to enter the character S

2, Delete the cursor position to the end of the character

3, the input character and the original character text cursor position to the end of the line character stitching write

4. Move the cursor to the original input position

Delete operation
// webTerminal.tsx ... const getCursorOffsetLength = (offsetLength: number, subString: string = '') => { let cursorOffsetLength = '' for (let offset = 0; offset < offsetLength; offset++) { cursorOffsetLength += subString } return cursorOffsetLength } ... case TERMINAL_INPUT_KEY.BACK: if (currentOffsetLength > prefix.length) { const cursorOffSetLength = getCursorOffsetLength(totalOffsetLength - currentOffsetLength, '\x1b[D') // leave the original cursor position terminal._core.buffer.x = currentoffSetLength -1 terminal.write('\x1b[?K' + inputText.slice(currentOffsetLength-prefix.length)) terminal.write(cursorOffSetLength) inputText = `${inputText.slice(0,  currentOffsetLength - prefix.length - 1)}${inputText.slice(currentOffsetLength - prefix.length)}` } break ...

Where, the direct input at the end of the text will delete the cursor position character, if the character text is deleted at a position other than the end, the main process is as follows

Suppose you have ABC, with the cursor at the second position. When it deletes, the process is as follows:

1. Move the cursor to the second place and press the keyboard to delete the character

2, Clear the current cursor position to the end of the character

3. Stitch the remaining characters according to the offset

3. Move the cursor to the original input position

Enter the operation
// webTerminal.tsx ... let inputText = '' let currentIndex = 0 let inputTextList = [] const handleInputText = () => { terminal.write('\r\n') if (! inputText.trim()) { terminal.prompt() return } if (inputTextList.indexOf(inputText) === -1) { inputTextList.push(inputText) currentIndex = inputTextList.length } terminal.prompt() } ... case TERMINAL_INPUT_KEY.ENTER: handleInputText() inputText = '' break ...

After pressing the Enter key, the input character text needs to be stored in an array to record the current text position for subsequent use

Up/down operation
// webTerminal.tsx ... case TERMINAL_INPUT_KEY.UP: { if (! inputTextList[currentIndex - 1]) break const offsetLength = getCursorOffsetLength(inputText.length, '\x1b[D') inputText = inputTextList[currentIndex - 1] terminal.write(offsetLength + '\x1b[?K' ) terminal.write(inputTextList[currentIndex - 1]) terminal._core.buffer.x = totalOffsetLength currentIndex-- break } ...

The main steps are as follows

Among other things, the up or down key pulls previously stored characters out, deletes them all, and then writes them.

Operate left/right
// webTerminal.tsx
...
case TERMINAL_INPUT_KEY.LEFT:
    if (currentOffsetLength > prefix.length) {
        terminal.write(key) // '\x1b[D'
    }
    break

case TERMINAL_INPUT_KEY.RIGHT:
    if (currentOffsetLength < totalOffsetLength) {
        terminal.write(key) // '\x1b[C'
    }
    break
...

Points to be perfected

1. Access WebSocket to realize communication between the server and the client

2. Access to SSH. Currently, the input operation of the terminal is only added, but our ultimate goal is to make it login to the server

The envisioned final implementation should look something like this

Doraemon is based on egg framework. You can use egg. Socket. IO to set up socket communication. This will be improved in the next article.

conclusion

First of all, this terminal is not finished here, because of time reasons, not finished. The above also lists some points to be improved, and the author will add the second or third part of this paper later to make further improvements one after another. I also tried to access sockets this week, but there were still some problems, so I decided to focus on handling some input operations in this article. Finally, if you have any questions about this article, please feel free to comment.

More and more

  • Official documentation: https://xtermjs.org/
  • Socket. IO document: https://eggjs.org/zh-cn/tutorials/socketio.html
  • Terminal special characters: https://blog.csdn.net/sunjiajiang/article/details/8513215