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">
<head>
<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>
</head>
<body>
<div id="terminal"></div>
</body>
</html>
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();
term.loadAddon(fit);
term.open(document.getElementById("terminal")!);
fit.fit();
/* ... */
}
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");
term.writeln("Starting...");
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();
initializeLogger("debug");
/* ... */
}
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:
- Build the Application: Run
npm run dev
to bundle your TypeScript code. - 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.
Conclusion
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.