본문으로 건너뛰기

riotermjs: Rio's terminal engine, now on the web

· 약 9분
Raphael Amorim
Accidentally built a terminal

Hey folks!

Two weeks ago I wrote about splitting Rio's engine into rio-vt and librio. The whole point of that split was that the engine, the part that took years to get right, should be embeddable anywhere. Today "anywhere" includes your browser.

riotermjs is Rio's terminal for the web: the same Rust VT core the desktop app ships, compiled to WebAssembly, wrapped in an xterm.js-shaped API, published on npm as rioterm (0.1.4).

npm i rioterm --save

Not a reimplementation

Every terminal on the web today reimplements the terminal in JavaScript. riotermjs does not. The parser, the grid, scrollback, selection, kitty keyboard protocol, bracketed paste, OSC 8 hyperlinks, all of it is librio, the exact code paths Rio runs on macOS and Linux, executing as wasm.

There is no PTY in a browser, so the engine hands you the transport instead. The whole integration surface is two directions of bytes:

import { open } from 'rioterm';

const { terminal } = await open(document.getElementById('term'), {
renderer: 'canvas', // or 'dom'
});

// bytes the terminal wants delivered to the child
terminal.onData((bytes) => socket.send(bytes));
// child output to display
socket.onmessage = (e) => terminal.write(new Uint8Array(e.data));

Wire those two lines to a WebSocket bridging a real shell, or to anything else that speaks bytes. The demo site plugs them into a whole Linux virtual machine (v86 restoring a pre-booted snapshot) and into a real bash running under WASIX. Client-side only, no server anywhere.

You pick the renderer per instance: a canvas painter or DOM rows of styled spans, with identical input, selection, and clipboard behavior. There is also a React wrapper, published as react-rioterm, and a fully headless mode if you bring your own renderer or want to run the engine in Node for tests.

The demo runs real software, in your tab

The demo site has no server behind it. Everything you type runs client-side, and the point is to prove that rioterm is a real terminal by driving it with real programs rather than a scripted echo. Two toggles sit above the terminal: PROGRAM switches between bash and Linux, RENDERER switches between the canvas and DOM painters live, on the same running session.

The rioterm demo running bash under WASIX, with the program and renderer toggles

Bash is a real bash. It is sharrattj/bash from the Wasmer registry, the same binary you would install, compiled to WASIX and running under the Wasmer SDK in the browser. rioterm feeds it exactly the way it feeds any backend: keystrokes go to the program's stdin, the program's stdout comes back through terminal.write. That is why the screenshot above works the way you would expect a shell to, ls and echo run, an unknown command gives you command not found, and reverse-i-search over your history is there because it is bash's own line editor doing it, not something the page faked. WASIX needs cross-origin isolation for SharedArrayBuffer, so the demo serves COOP/COEP headers; the bash webc is self-hosted and fetched in parallel with the runtime init to keep the cold start down to a couple of seconds.

Linux is a real kernel. Flip PROGRAM to Linux and the terminal is wired to the serial console of a v86 virtual machine, an x86-to-WebAssembly JIT running a Buildroot image. Booting a kernel in the visitor's tab would be slow, so instead the VM boots once during the build, its state is snapshotted and zstd-compressed, and the page restores that snapshot; v86 decompresses zstd states natively, so you land at a prompt in well under a second instead of watching init messages scroll. The serial port is just another byte transport to rioterm, onData in, terminal.write out, no SharedArrayBuffer required, which is why Linux is also the fallback anywhere the isolation headers are missing.

The renderer toggle is the quiet part worth noticing: it swaps the terminal between canvas and DOM while bash or Linux keeps running underneath, buffered scrollback replayed onto the new terminal, because the engine and the session outlive whichever renderer is drawing them.

The numbers

Byte-identical workloads, fixed 120x40 grid, medians of three runs in Chrome on Apple Silicon: xterm.js with its WebGL addon (its fastest renderer), and wterm with its DOM renderer, run on both of its VT cores, the default Zig core and the libghostty core. The benchmark suite lives in the repo under benchmark/, so run your own.

rioterm vs xterm.js vs wterm benchmark: rioterm leads every measured throughput metric

Metricxterm.js (webgl)rioterm 0.1.4 (canvas)rioterm 0.1.4 (dom)wterm 0.3.2 (zig)wterm 0.3.2 (ghostty)
Cold init33.7 ms18.6 ms19.6 ms17.0 ms12.1 ms
Plain text, parse and paint178 MB/s576 MB/s501 MB/s51 MB/s1.6 MB/s
ANSI colors, parse and paint108 MB/s251 MB/s247 MB/s136 MB/s1.2 MB/s
VT parsing, plain (headless)167 MB/s1105 MB/s1105 MB/s50 MB/s1 MB/s
VT parsing, ANSI (headless)103 MB/s231 MB/s231 MB/s140 MB/s1 MB/s
Full-screen TUI redraw120 fps (capped)120 fps (capped)120 fps (capped)120 fps (capped)120 fps †
Frame time p95, TUI redraw10.0 ms8.5 ms8.5 ms9.0 ms9.0 ms †

Both rioterm renderers are the same wasm engine, so the headless rows are identical; the renderer choice only moves paint-side numbers, and the DOM renderer posts the tightest frame times, a nice property for something that doubles as the accessibility-friendly option. wterm is interesting to include twice: the same DOM renderer on its two VT cores. Its minimal Zig core is fast to start and holds a real ANSI number; its libghostty core is the real Ghostty VT parser, vendored from source, but the published wasm build is size-first (Zig ReleaseSmall with SIMD switched off), so the scalar fallback runs and it parses at 1-2 MB/s, two-plus orders of magnitude behind everything else, unable to keep up with real output. Worth being precise there: that is the shipped npm build, not a debug build and not Ghostty's native speed, which is ReleaseFast with SIMD and an entirely different story; I include it because it is the only way to run wterm on libghostty today. Against rioterm, nothing led on throughput; wterm's Zig core came closest on frame p95, half a millisecond behind, where all engines are display-capped anyway.

† The frame scenarios write only a few KB per frame, which fits the budget even at 1-2 MB/s, so the libghostty core looks fine on these two rows. It is not: it is throughput-bound (the rows above), and under real terminal output those frames would blow the budget. Read those two cells as "not measured against its bottleneck," not as a pass.

Honest caveats are in the benchmark README: rioterm's grid lives in wasm linear memory that JS heap numbers don't count, and the two engines have different write-path designs (xterm queues asynchronously, rioterm parses synchronously), so the runs measure submit-to-parsed-and-painted for both.

Chasing these numbers also made the engine itself faster. Profiling the ANSI-heavy path found that SGR attributes were round-tripping the style intern table once per attribute; they now mutate a pending style that interns once per cell write, and intern verification became a single 128-bit compare. Those wins shipped back to rio-vt, so the desktop Rio benefits from the web port too.

Tested where it runs

One nice property of an engine with no DOM in it: the test suite runs the real wasm headless in Node. Over a hundred tests cover the things terminals actually get wrong: wide-character cells, erase semantics, scrollback anchoring, reflow on resize, the kitty keyboard protocol, mouse report encodings, bracketed paste gating, OSC 52 clipboard, wrapped OSC 8 hyperlinks, and the exact packed-cell format renderers consume. No browser automation, no flaky screenshots, just bytes in and state out.

Reconnect and search, built in

The two addons every cloud IDE reaches for first are part of the core. terminal.serialize() dumps scrollback plus screen as a VT stream with styling and OSC 8 hyperlinks preserved; write it into a fresh terminal on reconnect and the user keeps their buffer exactly as they left it. terminal.search() runs regex over the whole buffer inside the engine, and findNext()/findPrevious() cycle through matches, select them so the renderer highlights them, and scroll them into view. No addon wiring, no second copy of the buffer in JavaScript.

Running in production at Lovable

This is not a demo project. At Lovable we replaced xterm.js with rioterm for the internal terminal that powers admin access to preview sandboxes: multi-tab sessions over a WebSocket transport, with observer mode, session replay, and mobile touch support. The swap kept the existing component contract, deleted the xterm dependency entirely, and the parts that used to need xterm internals (touch scrolling, application cursor detection, bracketed paste) now sit on engine APIs that behave the same way the desktop Rio does.

Where this goes

The web target is now a first-class citizen of the Rio engine family: rio-vt for Rust, librio for C ABI consumers, rioterm for the web. One engine, one set of protocol behaviors, three ways in.

Try the live demo at riotermjs.pages.dev, star the repo if this is useful to you, and file issues for whatever you hit. The engine is the same one Rio users have been hammering for years, but the web wrapper is young (0.1.4) and moving fast.

All the best,

Raphael.