Creating an Interactive Terminal with XTerm.js

Creating an Interactive Terminal with XTerm.js

Welcome to this hands-on guide where we will integrate @wasmer/sdk with xterm.js to create a functional Bash terminal in the browser. This powerful combination leverages the capabilities of WebAssembly and WASIX, enabling you to run Bash and core Unix utilities interactively in a web environment.

The Wasmer.sh (opens in a new tab) website is a real-world example of how the JavaScript SDK can be used to provide a real terminal in the browser.

Create the Project

First, let's set up our project environment. Create a new directory for your project and initialize it using npm.

npm init -y

Add Dependencies

Once initialized, install @wasmer/sdk, xterm, and xterm-addon-fit by running:

npm install @wasmer/sdk xterm xterm-addon-fit

These packages are crucial; @wasmer/sdk is our WebAssembly runtime, while xterm and its add-on are used to create the terminal interface in the browser.

Install Vite

Next, we'll use vite for bundling our application. It's a fast, modern bundler and minifier.

npm install vite --save-dev

Package Scripts

Let's also set up a couple of scripts to assist development.

"scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"

These scripts provide quick commands to build your application (npm run build) and start a development server with live reloading (npm run dev).

Create the UI

In your project root, create an index.html file. This file will host our web terminal. It's a simple HTML document with a div element where the terminal interface will appear:

<!doctype html>
<html lang="en">
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Wasmer Shell</title>
    <script type="module" defer src="index.ts"></script>
    <div id="terminal"></div>

Add Styling

Then, import xterm's CSS in your TypeScript file for styling the terminal. This import is necessary for the terminal's visual appearance and functionality.

import "xterm/css/xterm.css";

Implement the TypeScript Logic

Now, we move to the core logic of our application in TypeScript. Start with importing necessary modules:

import type { Instance } from "@wasmer/sdk";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";

Initialize the SDK

Next, let's create a main() function and initialize @wasmer/sdk.

async function main() {
    const { Wasmer, init, initializeLogger } = await import("@wasmer/sdk");
    await init();
    /* ... */

You have may noticed that we're using a dynamic import (await import("@wasmer/sdk")) to pull in @wasmer/sdk rather than a "normal" import { ... } from "@wasmer/sdk".

This is a workaround for a bad interaction between bundlers, xterm, and the @wasmer/sdk threadpool. See ReferenceError: `document is not defined`` for more details.

Call init() to load and set up the WebAssembly environment required by @wasmer/sdk. It's crucial to do this before using any other functionality of the SDK.

Configure the Terminal

Inside main(), set up your terminal configuration with xterm.js. Here we create a new terminal instance and apply the fit add-on for a responsive layout:

async function main() {
    /* ... */
    const term = new Terminal({ cursorBlink: true, convertEol: true });
    const fit = new FitAddon();
    /* ... */

Integrate Bash with the Terminal

To integrate Bash, load the sharrattj/bash package using Wasmer.fromRegistry(). This package contains a WASIX-compiled version of Bash and its utilities. Upon loading, connect stdin, stdout, and stderr of the Bash instance to the xterm instance:

async function main() {
    /* ... */
    const pkg = await Wasmer.fromRegistry("sharrattj/bash");
    const instance = await pkg.entrypoint!.run();
    connectStreams(instance, term);

The connectStreams() function routes data between the Bash instance and the terminal. It ensures that user inputs and program outputs are correctly handled in the terminal:

const encoder = new TextEncoder();
function connectStreams(instance: Instance, term: Terminal) {
    const stdin = instance.stdin?.getWriter();
    term.onData(data => stdin?.write(encoder.encode(data)));
    instance.stdout.pipeTo(new WritableStream({ write: chunk => term.write(chunk) }));
    instance.stderr.pipeTo(new WritableStream({ write: chunk => term.write(chunk) }));

Take it for a Test Drive

Running npm run dev right now will show a terminal with a blinking cursor that doesn't seem to do anything. If you open up the dev tools, you'll see a message along the lines of this:

Library.mjs:11 Uncaught (in promise) Error: Unable to find "sharrattj/bash" in the registry
    at A2.wbg.__wbg_new_ab87fd305ed9004b (Library.mjs:11:46367)
    at 013772d2:0x2c846c
    at 013772d2:0x3a271a
    at 013772d2:0x143722
    at 013772d2:0x3266e4
    at 013772d2:0x3f1911
    at 013772d2:0x3eac6c
    at cA (Library.mjs:11:25455)
    at C2 (Library.mjs:11:25290)

This is a pretty unhelpful error message, but we can make troubleshooting a lot easier by enabling logging just after the await init().

async function main() {
    await init();
    /* ... */

Hitting save and reloading the page now gives us some more useful information.

DEBUG from_registry{specifier="sharrattj/bash"}: wasmer_js::runtime: Initializing the global runtime
DEBUG from_registry{specifier="sharrattj/bash"}: wasmer_js::tasks::scheduler: Spinning up the scheduler thread_id=0
DEBUG from_registry{specifier="sharrattj/bash"}:from_registry:query{package=sharrattj/bash}:query_graphql: wasmer_wasix::runtime::resolver::wapm_source: Querying the GraphQL API request.url=https://registry.wasmer.io/graphql request.method=POST
DEBUG from_registry{specifier="sharrattj/bash"}:from_registry:query{package=sharrattj/bash}:query_graphql: wasmer_js::tasks::scheduler: Sending message current_thread=0 scheduler_thread=0 msg=SpawnAsync(_)

WARN from_registry{specifier="sharrattj/bash"}: wasmer_js::tasks::scheduler:
        An error occurred while handling a message
        error=Failed to execute 'postMessage' on 'Worker':
            SharedArrayBuffer transfer requires self.crossOriginIsolated.

DEBUG from_registry{specifier="sharrattj/bash"}:from_registry:query{package=sharrattj/bash}:query_graphql: wasmer_wasix::runtime::resolver::wapm_source: close
DEBUG from_registry{specifier="sharrattj/bash"}:from_registry:query{package=sharrattj/bash}: wasmer_wasix::runtime::resolver::wapm_source: close
DEBUG from_registry{specifier="sharrattj/bash"}:from_registry: wasmer_wasix::bin_factory::binary_package: close

I've formatted the log output for readability, but it looks like we've run into SharedArrayBuffer and Cross-Origin Isolation issues!

Configure your Dev Server

The fix is to make sure Vite's dev server sends the correct COOP and COEP headers through vite.config.js.

import { defineConfig } from "vite";
export default defineConfig({
    server: {
        headers: {
            "Cross-Origin-Opener-Policy": "same-origin",
            "Cross-Origin-Embedder-Policy": "require-corp",

Test the Application

Now, it's time to actually see your Bash terminal in action:

  1. Build the Application: Run npm run dev to bundle your TypeScript code.
  2. Open the Application: Open http://localhost:5173/ (opens in a new tab) in your browser to see the terminal interface.

You should see a functional Bash terminal running in your browser, capable of executing basic *nix commands.

You might want to remove that initializeLogger() call at this point to avoid filling your console with spam.


Congratulations! You've successfully integrated a WebAssembly-powered Bash terminal in the browser using @wasmer/sdk and xterm.js. This setup demonstrates the incredible capabilities of WebAssembly in bringing complex server-side applications like Bash to the web client.

Feel free to explore and extend this application further. Perhaps you can integrate more utilities or enhance the UI/UX of the terminal. The possibilities are endless, and the power of WebAssembly makes it all possible in the browser.