0025 — Browser web client over a WebSocket transport
phux-web is a browser consumer of the wire (ADR-0017: consumers are not protocol-privileged), built in Rust→WASM.
Full source summary
phux-web is a browser consumer of the wire (ADR-0017: consumers are not protocol-privileged), built in Rust→WASM. It speaks the exact phux-protocol codec (wasm-safe per ADR-0024) and renders with the exact libghostty-vt engine — ghostty-vt.wasm loaded as a self-contained module and driven from Rust (phux-vt-web), not zig linked into the wasm binary, and not a JS terminal re-implementation. To reach it, the server grows a frame-level Transport abstraction with a WebSocket impl alongside UDS (one binary message = one FrameKind); the per-client dispatch loop and codec are transport-agnostic. The client is single-terminal: HELLO → ATTACH → feed TERMINAL_SNAPSHOT/TERMINAL_OUTPUT into the engine → paint the grid to a <canvas>; keystrokes become INPUT_KEY. Rendering is not shared with the multi-pane ratatui TUI, but the session kernel is: phux-web depends on phux-client-core, reversing this ADR's original no-shared-core call.
Status: Accepted Date: 2026-05-30
Context
ADR-0017 frames the TUI as one consumer among peers (agents, an SDK, a browser),
all speaking the same wire. Two things blocked a browser peer: the wire codec
was libghostty-coupled and unbuildable on wasm (fixed by ADR-0024), and the
server only listened on a Unix domain socket. Separately, a browser terminal
needs a VT engine; xterm.js-class renderers drop exactly the modern protocols
(kitty graphics, sixel, kitty keyboard) that are phux’s whole point, while
ghostty already compiles its VT engine to a standalone ghostty-vt.wasm module.
Decision
- Server
Transportseam.phux-serverabstracts its accept loop behind a frame-levelFrameReader/FrameWriter/Incomingtrait set. UDS frames stay length-prefixed on the byte stream; a WebSocket transport carries one encodedFrameKindper binary message. The dispatch loop and codec are unchanged. WebSocket is opt-in viaPHUX_WS_ADDR; UDS is always on. - Engine reuse, not reimplementation.
phux-vt-webloadsghostty-vt.wasm(self-contained: its only import isenv.log, it ships its own allocator) via the WebAssembly JS API and exposes a safe Rust surface over thelibghostty-vtC ABI. The browser renders with the same engine native phux uses. - Codec reuse.
phux-webdepends onphux-protocol(default, wasm-safe) and speaks the realFrameKindwire — no parallel JS/TS protocol. - Single-terminal consumer. The client attaches to a named
defaultsession, mirrors one terminal, and paints its grid to a<canvas>. Splits, layout, and keybind chrome (the multi-pane TUI’s job) are out of scope; the browser is a thin, focused projection.
Rationale
Reusing the codec and the engine is what makes the browser a peer rather than a lookalike: it gets every terminal protocol for free, forever, exactly as the native client does. The frame-level transport seam keeps the wire identical across UDS and WebSocket, so the server has one dispatch path, not two. Loading ghostty-vt as a separate module (rather than linking zig into the Rust wasm binary) sidesteps the rust+zig single-linear-memory problem and tracks how ghostty intends its wasm engine to be embedded.
Tradeoffs
- The browser↔engine boundary copies bytes across two wasm linear memories (the
Rust client and
ghostty-vt.wasm). Fine for a terminal; the @wterm ecosystem proves the model. - The renderers stay separate — canvas vs VT-to-stdout plus ratatui chrome — so
the paint layer is written twice. The decode-and-feed step is not: both
consumers drive
phux-client-core’s session kernel, so replica lifecycle, generation validation, ordering, and READY fences exist once. See the “sharedphux-client-core” entry under Alternatives for how that changed.
Alternatives considered
- A shared
phux-client-corefor native + web. The original plan, descoped here on the reasoning that the shared surface was tiny. That sizing was wrong and this decision has since been reversed in tree. The crate was extracted for the native client on 2026-05-31 — to fenceratatuibehind a compiler-enforced boundary (ADR-0020), not for the browser — andphux-webadopted it on 2026-08-04. It is roughly 16.7k lines across six modules, of whichphux-webimports three:engine,history, andsession. What survives from this ADR is the narrower rendering claim: the two consumers’ renderers do differ entirely, and they are still not shared. What was falsified is the conclusion drawn from it. - A JS terminal (xterm.js) fed by the wire. Rejected: it drops the modern protocols that distinguish phux, and reimplements the wire in TypeScript.