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
asciinemarecording 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.
| Problem | Detail |
|---|---|
| Hidden OS dependencies | Instant::now(), HashMap's random hasher, anything pulling libc transitively |
| No threads (by default) | wasm32-unknown-unknown is single-threaded without extra setup |
| No PTY, ever | There are no processes in a browser. This is a viewer, not a terminal. |
| The JS boundary costs copies | Every advance(bytes) copies a Uint8Array into linear memory |
| Rendering is someone else's | Canvas, WebGL, or DOM — and you must hand over cells, not pixels |
| Bundle size | Nobody 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-ptyboundary, 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
| # | Goal | Demonstrable by |
|---|---|---|
| 1 | terminal-core compiles for wasm32 | cargo build --target wasm32-unknown-unknown -p terminal-core |
| 2 | wasm-pack builds a package | pkg/ exists and loads in a browser |
| 3 | A <textarea> of escape sequences renders | Type \033[31mred and see red |
| 4 | An .cast file plays back | A recorded vim session replays in the browser |
| 5 | Damage-limited rendering at 60fps | The vtebench corpus plays smoothly |
| 6 (stretch) | A WebSocket-backed live session | Your 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
| Metric | Target | Method |
|---|---|---|
Bundle size (gzipped .wasm) | < 300 KB | wasm-opt -Oz, then `gzip -9 |
| Parse throughput | Compare with native | The same 10 MB corpus, timed |
Frames per second, vtebench replay | 60 | performance.now() in JS |
| Snapshot cost: JSON vs. packed | Find the crossover | Both, at 80×24 and 200×50 |
| Time to first render | < 200 ms | Including 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
| Trap | Symptom | Fix |
|---|---|---|
Instant::now() in the core | Panics at runtime on wasm32-unknown-unknown | Pass timestamps in, or feature-gate |
HashMap default hasher | Build failure: getrandom has no wasm32 backend | BTreeMap, or a fixed-seed hasher — and determinism is a virtue in a core anyway |
std::fs anywhere in the core | Build failure | Configuration is passed down, not read |
No console_error_panic_hook | Panics appear as "unreachable executed" | Set it in the constructor |
| Serializing the whole screen per frame | 10fps instead of 60 | Damage tracking, then packed arrays |
Many small advance() calls | Copy overhead dominates | Batch in JS before calling |
Forgetting wasm-opt | A 2 MB bundle instead of 250 KB | -Oz, always |
| Assuming threads | Anything spawning one fails | The core has none; keep it that way |
| Expecting a PTY | There are no processes | It is a viewer. Say so in the README. |
Tip: Run
cargo build --target wasm32-unknown-unknown -p terminal-corebefore 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-corehas no OS dependency — proven by a platform that cannot provide one. - The core is pure — proven by native and WASM producing identical snapshots.
-
RenderSnapshotis genuinely renderer-independent — proven by a canvas renderer. -
The
core/ptyboundary 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-wasmbuilding withwasm-pack, under 300 KB gzipped. -
A live demo page: paste escape sequences, or drop in an
.castfile, and see it render. -
Canvas rendering from
RenderSnapshot, damage-limited. -
wasm_and_native_agree_exactlypassing over the whole golden corpus. -
The five measurements, including the
xterm.jscomparison. - A README stating plainly that this is a viewer and why there is no PTY.
- The demo deployed somewhere a stranger can click it.