demarily.dev

Streaming a Terminal Session Over WebSocket

Streaming a Terminal Session Over WebSocket

I wanted people to be able to watch me code — live, in a terminal, like a Twitch stream but for the command line. Not a screen share. Not a video. The actual terminal output, rendered in real time in a browser.

The result is demarily.live. When I'm running a Claude Code session, anyone can open the site and see my terminal updating live. There's also a co-pilot mode where an authenticated viewer can send messages into the session.

Here's how the whole thing works.

Capturing terminal output

The capture layer is a bash script that wraps the claude CLI with script(1). If you haven't used script, it's a Unix utility that records everything written to a terminal — including ANSI escape codes for colors, cursor movement, and screen clearing.

script -q "$CAPTURE_FILE" claude

The capture file grows as the session runs. It contains the raw byte stream exactly as the terminal would see it. This is important — I'm not parsing or filtering anything. The client gets the same bytes the terminal gets.

The script also handles lifecycle: creating a secure temp directory for the capture file, starting the Dart server, managing auth keys, optionally starting a Cloudflare tunnel, and secure-wiping the capture file on shutdown.

The Dart server

The server is raw dart:io — no framework. It does three things:

1. Polls the capture file. A CaptureWatcher reads the file every 100ms and emits new bytes since the last read. I use polling instead of filesystem events because script(1) output doesn't reliably trigger inotify or FSEvents. The 100ms interval is a balance between latency and CPU — fast enough to feel live, cheap enough to not matter.

2. Manages WebSocket connections. Two modes: an unauthenticated read-only path for viewers, and an authenticated path for co-pilot mode that requires a guest key.

3. Serves metadata. Terminal dimensions, viewer count, theme configuration, and validated assets.

The WebSocket protocol

Messages are plain strings with prefix-based routing. No JSON, no protobuf — just simple prefixes for terminal dimensions, auth flow, viewer counts, and co-pilot messages. Everything else is raw terminal data, forwarded byte-for-byte from the capture file to all connected viewers.

Authentication

The co-pilot key system is designed to be secure by default:

I generate a key from the command line and share it with whoever I want to co-pilot.

The Flutter client

The viewer is a Flutter web app using the xterm package (a Dart port of xterm.js). It connects to the WebSocket, pipes incoming bytes into the terminal emulator, and renders the result.

The app has two modes:

The connection lifecycle is managed with BLoC: disconnectedconnectingauthenticatingconnectederror. Auto-reconnect on disconnect.

What makes it feel live

The key insight is that terminal output is already a streaming format. A terminal emulator just processes a byte stream. By capturing that stream with script(1) and forwarding it over WebSocket, the remote viewer's terminal emulator gets the exact same input as the local one. Cursor movements, color changes, screen clears — it all just works because xterm handles the escape codes the same way any terminal would.

There's no transcoding, no parsing, no frame rendering. It's bytes in, bytes out. That's why the architecture is so simple and why the latency is basically just network round-trip plus the 100ms polling interval.

The theme system

The terminal viewer supports custom themes loaded from JSON files. Each theme defines colors, fonts (from a whitelist), frame style, banner text, and logo. Assets are validated by file magic bytes to prevent arbitrary uploads.

I have a few themes I rotate between. The default is a dark GitHub-style palette with JetBrains Mono.

What I learned

Polling a file every 100ms sounds hacky, but it's the right call here. Filesystem watchers are unreliable across platforms for script(1) output, and 100ms is imperceptible to a human viewer. Sometimes the boring solution is the correct one.

The whole system — capture script, Dart server, Flutter client — is about 2,000 lines of code. Most of the complexity is in the auth and security layers, not the streaming itself. The actual "stream terminal bytes to a browser" part is maybe 200 lines.