Project 6: A WebAssembly Terminal Viewer

1–2 weeks · ●●○○○ · touches the core's portability — proven publicly

The easiest project here, and the one that proves the most about your architecture. If terminal-core really has no OS dependency, this is a weekend. If it does not, this project finds out exactly where.


1. The Problem

Your terminal core is a pure function from bytes to screen state, and it currently runs in exactly one place: your machine. It should be able to run:

  • in a browser, replaying an asciinema recording with real fidelity
  • as a documentation widget that shows what a command actually outputs
  • as a remote-session viewer over a WebSocket, with the PTY on a server
  • in a CI report, rendering the terminal output of a failed test

None of these need a PTY. All of them need a terminal emulator that runs where there are no processes.


2. Why It Is Hard

It mostly is not — and that is the finding. The difficulty is concentrated in a few specific places.

ProblemDetail
Hidden OS dependenciesInstant::now(), HashMap's random hasher, anything pulling libc transitively
No threads (by default)wasm32-unknown-unknown is single-threaded without extra setup
No PTY, everThere are no processes in a browser. This is a viewer, not a terminal.
The JS boundary costs copiesEvery advance(bytes) copies a Uint8Array into linear memory
Rendering is someone else'sCanvas, WebGL, or DOM — and you must hand over cells, not pixels
Bundle sizeNobody downloads a 6 MB WASM blob for a terminal widget

Note: The "no PTY" limitation is clarifying, not annoying. It is precisely the terminal-core / terminal-pty boundary, enforced by a platform that physically cannot violate it. If your core compiles here, the boundary is real.


3. The Design

   ┌─────────────────────────────────────────────────────────────────┐
   │  BROWSER                                                        │
   │   ┌─────────────┐   snapshot JSON / typed arrays  ┌───────────┐ │
   │   │ WASM        │ ───────────────────────────────▶│ renderer  │ │
   │   │ terminal-   │                                 │ (canvas)  │ │
   │   │   core      │◀─── advance(bytes) ─────────────│           │ │
   │   └─────────────┘                                 └───────────┘ │
   │         ▲                                                       │
   └─────────│───────────────────────────────────────────────────────┘
             │ bytes from: an .cast file, a WebSocket, or a <textarea>
#![allow(unused)]
fn main() {
// crates/terminal-wasm/src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct WasmTerminal {
    inner: terminal_core::Terminal,
}

#[wasm_bindgen]
impl WasmTerminal {
    #[wasm_bindgen(constructor)]
    pub fn new(rows: usize, cols: usize) -> WasmTerminal {
        // Rust panics become "unreachable executed" in WASM without this.
        // With it, you get a real message and a stack trace in the console.
        console_error_panic_hook::set_once();
        WasmTerminal { inner: terminal_core::Terminal::new(rows, cols, Default::default()) }
    }

    /// Feed bytes. wasm-bindgen copies the Uint8Array into linear memory, so
    /// prefer FEWER, LARGER calls — batching in JS is a real optimization.
    pub fn advance(&mut self, bytes: &[u8]) {
        self.inner.advance(bytes);
    }

    pub fn resize(&mut self, rows: usize, cols: usize) {
        self.inner.resize(rows, cols);
    }

    /// A structured snapshot for the JS renderer. JSON is the easy version;
    /// see the packed-typed-array optimization below.
    pub fn snapshot_json(&self) -> String {
        self.inner.snapshot_json()
    }

    /// Only the rows that changed. This is what makes 60fps playback possible —
    /// serializing the whole screen every frame is the bottleneck.
    pub fn damaged_rows(&self) -> Vec<usize> {
        self.inner.damage().iter_dirty().collect()
    }

    pub fn clear_damage(&mut self) { self.inner.clear_damage(); }

    /// Replies (CSI 6n, DA) that a real terminal would write back to the PTY.
    /// A viewer has nowhere to send them — but a WebSocket-backed session does.
    pub fn take_replies(&mut self) -> Vec<u8> { self.inner.take_replies() }
}
}

The performance boundary

The naïve version serializes the whole screen to JSON every frame. At 80×24 that is fine; at 200×50 during a vtebench replay it is not.

#![allow(unused)]
fn main() {
/// Hand JS a PACKED TYPED ARRAY instead of JSON. One cell = 12 bytes:
///   [0..4]  char as u32
///   [4..8]  fg as RGBA
///   [8..12] bg as RGBA + flags packed into the alpha byte
/// JS reads it with a DataView, with zero parsing.
#[wasm_bindgen]
pub fn snapshot_packed(&self) -> Vec<u8> { /* ... */ }
}

Measure both. The JSON version is fine for a documentation widget and too slow for a 60fps replay, and knowing where the crossover is is the interesting part.


4. Milestones

#GoalDemonstrable by
1terminal-core compiles for wasm32cargo build --target wasm32-unknown-unknown -p terminal-core
2wasm-pack builds a packagepkg/ exists and loads in a browser
3A <textarea> of escape sequences rendersType \033[31mred and see red
4An .cast file plays backA recorded vim session replays in the browser
5Damage-limited rendering at 60fpsThe vtebench corpus plays smoothly
6 (stretch)A WebSocket-backed live sessionYour server's PTY, in a browser tab

Milestone 1 is the whole architectural point and it may already pass. Run it before anything else.


5. The Tests

#![allow(unused)]
fn main() {
#[test]
fn core_builds_for_wasm32() {
    // The check that matters. Put it in CI on day one.
    // cargo build --target wasm32-unknown-unknown -p terminal-core
}

#[wasm_bindgen_test]
fn wasm_terminal_parses_and_snapshots() {
    let mut t = WasmTerminal::new(24, 80);
    t.advance(b"\x1b[31mred\x1b[0m\n");
    let json = t.snapshot_json();
    assert!(json.contains("red"));
}

#[wasm_bindgen_test]
fn wasm_and_native_agree_exactly() {
    // The core is PURE, so the same bytes must give the same screen in both.
    // Any difference is a hidden platform dependency — which is exactly what
    // this project exists to find.
    for case in golden_cases() {
        let mut w = WasmTerminal::new(24, 80);
        w.advance(&recording_bytes(&case));
        assert_eq!(w.snapshot_json(), native_snapshot_json(&case), "{}", case.name);
    }
}

#[wasm_bindgen_test]
fn damage_tracking_works_across_the_boundary() {
    let mut t = WasmTerminal::new(24, 80);
    t.clear_damage();
    t.advance(b"hello");
    assert_eq!(t.damaged_rows(), vec![0]);
}
}
wasm-pack test --headless --firefox crates/terminal-wasm

6. The Measurement

MetricTargetMethod
Bundle size (gzipped .wasm)< 300 KBwasm-opt -Oz, then `gzip -9
Parse throughputCompare with nativeThe same 10 MB corpus, timed
Frames per second, vtebench replay60performance.now() in JS
Snapshot cost: JSON vs. packedFind the crossoverBoth, at 80×24 and 200×50
Time to first render< 200 msIncluding WASM instantiation
wasm-pack build --release --target web crates/terminal-wasm
wasm-opt -Oz pkg/terminal_wasm_bg.wasm -o pkg/opt.wasm
gzip -9 -c pkg/opt.wasm | wc -c

Compare with xterm.js, which is the incumbent: bundle size, parse throughput, and fidelity on your golden corpus. You will lose on bundle size (it is JS, and mature) and may well win on parse throughput. Report both.


7. Known Traps

TrapSymptomFix
Instant::now() in the corePanics at runtime on wasm32-unknown-unknownPass timestamps in, or feature-gate
HashMap default hasherBuild failure: getrandom has no wasm32 backendBTreeMap, or a fixed-seed hasher — and determinism is a virtue in a core anyway
std::fs anywhere in the coreBuild failureConfiguration is passed down, not read
No console_error_panic_hookPanics appear as "unreachable executed"Set it in the constructor
Serializing the whole screen per frame10fps instead of 60Damage tracking, then packed arrays
Many small advance() callsCopy overhead dominatesBatch in JS before calling
Forgetting wasm-optA 2 MB bundle instead of 250 KB-Oz, always
Assuming threadsAnything spawning one failsThe core has none; keep it that way
Expecting a PTYThere are no processesIt is a viewer. Say so in the README.

Tip: Run cargo build --target wasm32-unknown-unknown -p terminal-core before you start writing anything. If it already passes — and if you followed Section 5, it should — this project is a weekend and you are mostly writing JavaScript. If it fails, the error message is the whole syllabus, and fixing it improves the core for every other consumer too.


What This Proves

The deliverable is not really the viewer. It is this list:

  • terminal-core has no OS dependency — proven by a platform that cannot provide one.
  • The core is pure — proven by native and WASM producing identical snapshots.
  • RenderSnapshot is genuinely renderer-independent — proven by a canvas renderer.
  • The core/pty boundary is real — proven by a platform with no processes.
  • The damage interface is usable across a language boundary.

Five architectural claims from Section 5, each converted from an assertion into a test.


Deliverables

  • terminal-wasm building with wasm-pack, under 300 KB gzipped.
  • A live demo page: paste escape sequences, or drop in an .cast file, and see it render.
  • Canvas rendering from RenderSnapshot, damage-limited.
  • wasm_and_native_agree_exactly passing over the whole golden corpus.
  • The five measurements, including the xterm.js comparison.
  • A README stating plainly that this is a viewer and why there is no PTY.
  • The demo deployed somewhere a stranger can click it.

Next: Project 7 — A Terminal Benchmark Suite