Last updated 2024-04-28 22:41:21

Creating A Browser-based Interactive Terminal (Using XtermJS And NodeJS)

Table of contents

The initial version of my implementation didn't support interactive sessions.
I have now updated the code to support this !
So now VIM works fine 🎉 🎉 🎉 🎉 🎉 🎉 🎉
Enjoy 🥳 🥳

Why a terminal in the browser?

A browser-based terminal has a lot of use cases.

Instead of sharing server access through keys, you can grant them access to a web-based terminal that is behind a login screen, one that you control. For example, Digital Ocean provides browser-based terminal access to droplets and Play with Dockers [↗] provides terminal access to containers right within the browser.

If you create developer tools chances are at some point you will need to create an embedded terminal.

One other interesting use case is creating controlled terminal access, by this, I mean being able to disable certain commands so they can not be used.

Get the source code

The source code used for the interactive terminal can be found on Github [↗]. The README.md file contains all the steps you need to get it up and running.

The purpose of this post is to explain how the different parts of the code work.

How it works

XtermJS structure [→]

There is the frontend implementation which is a terminal emulator recreated using good old HTML, CSS, and Javascript.

Whenever the user types a command in the emulator it's sent over to a NodeJS backend through a WebSocket connection.

The reason for sending it over WebSocket instead of the standard HTTP protocol is because of how an actual terminal functions.

When the backend gets the command from the emulator it runs the command against an actual terminal. Now if the input is a command like ls well it returns the list of files and folders to the emulator, perfect one-way communication.

However, for commands like netstat, the terminal refreshes the screen with changes and it will be difficult to convey these changes back and forth over HTTP. With WebSocket, the real terminal can send over output whenever it has something and the frontend emulator can too. This way even progress bars can be emulated without problems.

Interesting facts: Did you know most backends will fail this validation test [↗]?

Frontend setup

What you see in the browser is an emulator as mentioned earlier, it's meant to emulate the experience you get with a real terminal. For this demo, I used XtermJS [↗].

It provides the look of a terminal and also a bunch of hooks. Xterm does not handle the interpretations of terminal commands, it only provides the interface and captures and makes the user's input available to you through keystroke hooks. It also deals with character encoding and all the little things that deliver that terminal experience.

You can pull in XtermJS as a Node module or use a CDN. I went with the latter for this demo.

There is also the stylesheet for the emulator styling.

01: <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css" />

And the Javascript library for initiating and accessing the functionalities of XtermJS such as the hooks.

01: <script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.js"></script>

You will also need to let XtermJS know which HTML element to embed the terminal DOM elements.

In the case of the demo thus:

01: <div id="terminal"></div>

Ok so now let's look at some of the frontend Javascript code. The following code instantiates Xterm.

01: var term = new window.Terminal({ 02: cursorBlink: true 03: }); 04: term.open(document.getElementById('terminal'));

View on Github [↗]

Once we have Xterm pulled in through a CDN it adds the Terminal function. This contains everything we need to work with Xterm, we just need to instantiate it with all the options we want, in the case of the demo we just set the cursor to blink.

The last line binds the terminal to an HTML element(DIV).

There is also some WebSocket code we should look at:

The line below instantiates the WebSocket and establishes a connection to the backend.

01: const socket = new WebSocket("ws://localhost:6060");

View on Github [↗]

Whenever a user types a command and hits enter, it's sent over the socket to the backend. There is a newline character at the end of the command. This is added so that when the backend runs the command against the actual terminal the newline will simulate the hit of the enter key after the command (speaking loosely).

01: socket.send(command + '\n');

View on Github [↗]

Once a command is sent to the backend the onmessage handler will fire up on every response sent back from the backend. In the case of our demo, the handler outputs the data directly into the emulator.

01: socket.onmessage = (event) => { 02: term.write(event.data); 03: }

View on Github [↗]

Backend implementation

Now let's talk about the backend implementation. It acts as a bridge between the emulator and the real terminal, forwarding inputs and outputs between them.

It does that through a WebSocket connection using the WS [↗] Node package.

When the frontend sends over a command to the backend we need to forward this over to the actual terminal. We can use NodeJS's exec method to run the command but there is one big problem.

exec closes an execution when it hits its first output, meaning if the output from running a command is say a question that requires the user's input, well the user won't have the chance to provide the answer since exec would have closed that connection after the question is displayed.

To get around this we can manipulate the standard buffer [↗] as part of our command instruction to exec or just throw in another Node package node-pty [↗]

We create a custom-spawned process using node pty, this process will create either a PowerShell or Bash terminal shell instance depending on the OS.

01: var shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash'; 02: var ptyProcess = pty.spawn(shell, [], { 03: name: 'xterm-color', 04: // cwd: process.env.HOME, 05: env: process.env, 06: });

View on Github [↗]

The code below is responsible for sending commands to the actual terminal whenever the frontend sends them over. ptyProcess.write performs a stdin directly to the terminal.

01: ws.on('message', command => { 02: ptyProcess.write(command); 03: })

And we listen for the response from the terminal using the code below

01: ptyProcess.on('data', function (data) { 02: ws.send(data); 03: console.log(data); 04: });

View on Github [↗]

ws.send(data); on line 02 sends the response back to the frontend to be displayed to the user.

And that's pretty much it. 😊