Introduction

tty-web is a web-based terminal emulator that opens a real PTY in the browser over WebSocket.

demo

Features

Getting Started

Running

tty-web --address 127.0.0.1 --port 9090 --shell /bin/zsh

Then open http://127.0.0.1:9090 in a browser.

CLI Flags

Every flag can also be set via an environment variable.

FlagEnvDefaultDescription
--addressTTY_WEB_ADDRESS127.0.0.1Listen address
--portTTY_WEB_PORT9090Listen port
--shellTTY_WEB_SHELL/bin/bashShell to spawn
--log-levelTTY_WEB_LOG_LEVELinfoLog level (trace, debug, info, warn, error)
--log-formatTTY_WEB_LOG_FORMATtextLog output format (text, json)
--pwdTTY_WEB_PWDinheritedWorking directory for new shell sessions
--scrollback-limitTTY_WEB_SCROLLBACK_LIMIT256Scrollback buffer size in KiB

Docker

Pre-built images are available for linux/amd64 and linux/arm64 in two variants:

VariantTagsDescription
minimallatest, <version>Single static binary (~5 MB), ideal for COPY --from
playgroundplayground, <version>-playgroundUbuntu with Python, Node, Go, Rust, Neovim

Minimal (default)

Scratch-based image with a single static binary. Use as a source for COPY --from:

COPY --from=ghcr.io/alviner/tty-web:latest /tty-web /usr/local/bin/tty-web

Playground

docker run --rm -p 9090:9090 ghcr.io/alviner/tty-web:playground

Override the default shell:

docker run --rm -p 9090:9090 ghcr.io/alviner/tty-web:playground \
  tty-web --shell /bin/sh

Sessions

Each WebSocket connection is backed by a persistent session identified by a UUID v4. The PTY and shell process live independently of the WebSocket — closing a tab or losing connectivity does not kill the shell.

Reconnect

On first connect the server assigns a UUID and the client updates the browser URL to /?sid=<uuid> via history.replaceState. On reconnect the client reads sid from the URL and passes it as a query parameter. The server replays the scrollback buffer and then streams live output — no gaps. From the user's perspective the terminal picks up where it left off.

The scrollback is an event log of output chunks and window-size changes. --scrollback-limit (in KiB, default 256) caps the total byte cost of stored events. When the budget is exceeded, entire events are evicted from the front — escape sequences are never split mid-stream. On reconnect the server replays the log as Output and WindowSize protocol frames followed by a ReplayEnd marker.

Reconnection uses exponential backoff starting at 1 s up to a maximum of 5 s.

Share a session

Open a second tab with ?sid=<uuid> in the page URL:

http://localhost:9090/?sid=<uuid>

All tabs see the same output and can send input simultaneously. The session ID is printed to the browser console on connect.

View mode

Append &view to a session URL to connect as a read-only observer:

http://localhost:9090/?sid=<uuid>&view

Terminal output is visible but all keyboard input and resize events are ignored. The viewer's terminal automatically matches the interactive client's window size — when the interactive client resizes, all viewers receive the updated dimensions via the 0x13 (Window size) protocol command.

Useful for demos, monitoring, and pair-programming.

Lifecycle

A session is removed when:

  • the shell process exits and no clients are attached (immediately), or
  • the shell process exits while clients are still attached (as soon as the last client disconnects), or
  • no client is attached for 60 seconds (orphan timeout).

For internal constants and implementation details, see the API Reference.

Wire Protocol

All WebSocket messages are binary frames. The first byte is the command, the rest is the payload.

Commands

DirectionCmdPayloadDescription
client → server0x00raw bytesTerminal input
client → server0x01rows(u16 BE) + cols(u16 BE)Resize
server → client0x00raw bytesTerminal output
server → client0x10UUID stringSession ID
server → client0x12Shell exited
server → client0x13rows(u16 BE) + cols(u16 BE)Window size
server → client0x14Replay end

Close codes

CodeMeaning
4404Session not found (invalid or expired sid)

Handshake sequence

1. Handshakeresolve / create session2. Replay3. Streaming4. ShutdownWS connect (?sid, view)0x10 Session ID0x13 Window size0x00 Output (scrollback)0x13 Window size(scrollback)0x14 Replay end0x00 Output0x00 Input0x01 Resize0x13 Window size(broadcast)0x00 Output0x12 Shell exitedClientServerClientServer
1. Handshakeresolve / create session2. Replay3. Streaming4. ShutdownWS connect (?sid, view)0x10 Session ID0x13 Window size0x00 Output (scrollback)0x13 Window size(scrollback)0x14 Replay end0x00 Output0x00 Input0x01 Resize0x13 Window size(broadcast)0x00 Output0x12 Shell exitedClientServerClientServer
1. Handshakeresolve / create session2. Replay3. Streaming4. ShutdownWS connect (?sid, view)0x10 Session ID0x13 Window size0x00 Output (scrollback)0x13 Window size(scrollback)0x14 Replay end0x00 Output0x00 Input0x01 Resize0x13 Window size(broadcast)0x00 Output0x12 Shell exitedClientServerClientServer
1. Handshakeresolve / create session2. Replay3. Streaming4. ShutdownWS connect (?sid, view)0x10 Session ID0x13 Window size0x00 Output (scrollback)0x13 Window size(scrollback)0x14 Replay end0x00 Output0x00 Input0x01 Resize0x13 Window size(broadcast)0x00 Output0x12 Shell exitedClientServerClientServer
1. Handshakeresolve / create session2. Replay3. Streaming4. ShutdownWS connect (?sid, view)0x10 Session ID0x13 Window size0x00 Output (scrollback)0x13 Window size(scrollback)0x14 Replay end0x00 Output0x00 Input0x01 Resize0x13 Window size(broadcast)0x00 Output0x12 Shell exitedClientServerClientServer
  1. The client opens a WebSocket to /ws with an optional sid query parameter and an optional view flag.
  2. The server resolves an existing session or creates a new one. If sid is provided but not found, the connection is closed with code 4404.
  3. The server sends 0x10 with the session UUID. The client enters replay mode (input suppressed, terminal reset).
  4. The server sends 0x13 with the current PTY window size. View-mode clients use this to match their terminal dimensions to the interactive session before scrollback replay.
  5. The server replays the scrollback event log as a sequence of 0x00 (output) and 0x13 (window size) frames — one per stored event. The subscription is established atomically so no messages are lost between the replay and live streaming.
  6. The server sends 0x14 (replay end). The client exits replay mode, shows the cursor, and sends its initial resize.
  7. The main loop begins: output is forwarded as 0x00 frames, input and resize commands are read from the client. In view mode, client input is ignored.
  8. When an interactive client sends a resize (0x01), the server updates the PTY and broadcasts 0x13 to all connected clients.
  9. When the shell process exits, the server sends 0x12 and the connection closes.

Development

Build

make build     # debug build
make release   # release build (LTO + strip, musl target)
make docker    # build Docker image

All available Make targets:

TargetDescription
buildDebug build via cargo build
runRun the binary via cargo run
releaseRelease build with LTO and symbol stripping
cleancargo clean
fmtFormat code with cargo fmt
lintLint with cargo clippy -- -D warnings
checkcargo check
dockerBuild release binary and playground Docker image
docker-minimalBuild release binary and minimal (scratch) Docker image
docsBuild mdbook + cargo doc into docs/book/
docs-serveServe docs locally with live reload

Architecture

The codebase is split into focused modules:

confighealthmainptysessionstatic_filesterminalwebws
confighealthmainptysessionstatic_filesterminalwebws
confighealthmainptysessionstatic_filesterminalwebws
confighealthmainptysessionstatic_filesterminalwebws
confighealthmainptysessionstatic_filesterminalwebws

Each module has doc-comments describing its responsibilities — see the API Reference for details.

API Reference

Auto-generated from source code doc-comments via rustdoc.

Open API Reference