Finalize Go-only migration, runtime hardening, and CI/container optimization
This commit consolidates the full repository transition to a Go-first codebase and captures the follow-up performance/reliability work completed in the same stream. Highlights: - Remove Python implementation and test suites (, , , ) and retire Python-specific docs/instructions. - Move and standardize static web assets under , updating Bun/TypeScript build paths and server static resolution logic. - Rewrite developer workflow to Makefile-first Go targets (vet/test/race/coverage/fuzz/build) and align repository guidance/docs accordingly. - Update Docker and CI/CD for leaner artifacts: - switch to Alpine-based multi-stage build with stripped Go binary - install only minimal runtime deps (, ) - tighten Docker build context via - ensure workflows build/publish the target. - Improve runtime correctness/latency and reduce duplication: - explicit WebSocket outbound frame typing (text vs binary) instead of payload-byte heuristics - SSE activity fan-out outside global lock and safer subscriber lifecycle - shared session output/snapshot helpers to reduce duplicated logic - restart-safe channel lifecycle for Docker watcher/stats start-stop-start flows - faster screenshot cold-start path (poll-until-ready within timeout vs fixed sleep). - Add/expand regression coverage for the above lifecycle and helper paths. Validation run: - bun run build [32mBundled 3 modules in 10ms[0m [34mterminal.js[33m 0.68 MB [2m(entry point)[0m (Bun typecheck + bundle) - cd go && go vet ./... cd go && go test ./... ok github.com/rcarmo/webterm-go-port/cmd/webterm (cached) ok github.com/rcarmo/webterm-go-port/internal/terminalstate (cached) ok github.com/rcarmo/webterm-go-port/webterm (cached) cd go && go test ./webterm -coverprofile=coverage.out && go tool cover -func=coverage.out ok github.com/rcarmo/webterm-go-port/webterm (cached) coverage: 81.0% of statements github.com/rcarmo/webterm-go-port/webterm/cli.go:14: RunCLI 51.6% github.com/rcarmo/webterm-go-port/webterm/config.go:25: DefaultConfig 100.0% github.com/rcarmo/webterm-go-port/webterm/config.go:29: LoadLandingYAML 82.6% github.com/rcarmo/webterm-go-port/webterm/config.go:70: LoadComposeManifest 76.9% github.com/rcarmo/webterm-go-port/webterm/config.go:114: extractLabel 92.3% github.com/rcarmo/webterm-go-port/webterm/config.go:138: asString 80.0% github.com/rcarmo/webterm-go-port/webterm/constants.go:27: EnvBool 50.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:30: Read 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:56: NewDockerExecSession 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:72: Open 90.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:99: Start 85.7% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:119: readLoop 83.3% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:143: handleOutput 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:153: createExec 75.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:183: startExecSocket 60.7% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:219: resizeExec 83.3% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:237: Close 90.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:250: Wait 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:257: SetTerminalSize 81.8% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:274: ForceRedraw 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:281: SendBytes 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:294: SendMeta 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:298: IsRunning 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:304: GetReplayBuffer 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:308: GetScreenSnapshot 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:316: UpdateConnector 80.0% github.com/rcarmo/webterm-go-port/webterm/docker_http.go:23: DockerSocketPath 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_http.go:37: newUnixHTTPClient 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_http.go:47: sharedUnixClient 91.7% github.com/rcarmo/webterm-go-port/webterm/docker_http.go:64: unixJSONRequest 84.2% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:33: NewDockerStatsCollector 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:47: Available 72.7% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:64: Start 80.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:78: Stop 75.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:90: AddService 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:101: RemoveService 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:115: GetCPUHistory 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:124: pollLoop 95.2% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:160: discoverContainers 65.4% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:198: pollContainer 77.8% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:223: calculateCPUPercent 69.2% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:260: RenderSparklineSVG 89.3% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:299: max 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:306: toAnyMap 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:323: toStringMap 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:331: toAnySlice 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:340: toStringSlice 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:351: toUint 91.7% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:376: toInt 85.7% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:34: NewDockerWatcher 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:54: hasWebtermLabel 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:60: isAutoLabel 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:67: getContainerCommand 80.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:76: getContainerTheme 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:81: getContainerName 42.9% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:93: containerToSlug 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:98: addContainer 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:119: removeContainer 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:143: listLabeledContainers 94.1% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:168: handleEvent 80.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:202: watchEvents 85.7% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:239: ScanExisting 60.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:249: Start 83.3% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:265: Stop 81.8% github.com/rcarmo/webterm-go-port/webterm/identity.go:12: GenerateID 94.1% github.com/rcarmo/webterm-go-port/webterm/normalize.go:13: FilterDASequences 83.3% github.com/rcarmo/webterm-go-port/webterm/replay.go:14: NewReplayBuffer 66.7% github.com/rcarmo/webterm-go-port/webterm/replay.go:21: Add 100.0% github.com/rcarmo/webterm-go-port/webterm/replay.go:43: Bytes 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:95: OnData 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go💯 OnBinary 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:105: OnMeta 0.0% github.com/rcarmo/webterm-go-port/webterm/server.go:107: OnClose 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:112: NewLocalServer 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:163: findStaticPath 62.5% github.com/rcarmo/webterm-go-port/webterm/server.go:184: markRouteActivity 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:207: enqueueWSFrame 77.8% github.com/rcarmo/webterm-go-port/webterm/server.go:233: stopWSClient 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:246: wsSender 80.0% github.com/rcarmo/webterm-go-port/webterm/server.go:256: createTerminalSession 66.7% github.com/rcarmo/webterm-go-port/webterm/server.go:278: clampInt 60.0% github.com/rcarmo/webterm-go-port/webterm/server.go:288: parseResizePayload 88.9% github.com/rcarmo/webterm-go-port/webterm/server.go:303: handleWebSocket 81.1% github.com/rcarmo/webterm-go-port/webterm/server.go:425: chooseRouteForScreenshot 50.0% github.com/rcarmo/webterm-go-port/webterm/server.go:440: screenshotTTL 66.7% github.com/rcarmo/webterm-go-port/webterm/server.go:457: handleScreenshot 55.7% github.com/rcarmo/webterm-go-port/webterm/server.go:541: handleCPUSparkline 94.4% github.com/rcarmo/webterm-go-port/webterm/server.go:566: handleEvents 76.0% github.com/rcarmo/webterm-go-port/webterm/server.go:602: toIntFromQuery 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:609: dashboardTiles 81.8% github.com/rcarmo/webterm-go-port/webterm/server.go:631: handleTiles 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:636: getWSURL 65.2% github.com/rcarmo/webterm-go-port/webterm/server.go:671: handleRoot 56.8% github.com/rcarmo/webterm-go-port/webterm/server.go:732: htmlEscape 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:736: htmlAttrEscape 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:740: handleHealth 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:744: setupDockerFeatures 40.0% github.com/rcarmo/webterm-go-port/webterm/server.go:791: shutdown 62.5% github.com/rcarmo/webterm-go-port/webterm/server.go:814: Handler 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:829: Run 77.8% github.com/rcarmo/webterm-go-port/webterm/session.go:31: OnData 0.0% github.com/rcarmo/webterm-go-port/webterm/session.go:32: OnBinary 0.0% github.com/rcarmo/webterm-go-port/webterm/session.go:33: OnMeta 0.0% github.com/rcarmo/webterm-go-port/webterm/session.go:34: OnClose 0.0% github.com/rcarmo/webterm-go-port/webterm/session.go:36: dispatchSessionOutput 100.0% github.com/rcarmo/webterm-go-port/webterm/session.go:47: snapshotFromTracker 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:20: NewSessionManager 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:34: SetSessionFactory 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:40: defaultSessionFactory 87.5% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:57: splitCommand 75.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:65: shlexSplit 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:69: AddApp 87.5% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:88: RemoveApp 87.5% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:101: Apps 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:107: AppBySlug 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:114: GetDefaultApp 80.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:123: NewSession 50.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:167: OnSessionEnd 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:176: CloseAll 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:189: CloseSession 87.5% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:201: GetSession 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:207: GetSessionByRouteKey 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:217: GetSessionIDByRouteKey 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:223: GetFirstRunningSession 85.7% github.com/rcarmo/webterm-go-port/webterm/shellsplit.go:5: shlexSplitImpl 100.0% github.com/rcarmo/webterm-go-port/webterm/slugify.go:13: Slugify 100.0% github.com/rcarmo/webterm-go-port/webterm/svg_exporter.go:35: RenderTerminalSVG 92.6% github.com/rcarmo/webterm-go-port/webterm/svg_exporter.go:113: colorToHex 87.5% github.com/rcarmo/webterm-go-port/webterm/svg_exporter.go:139: isHex 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:37: NewTerminalSession 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:49: Open 86.7% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:90: Start 85.7% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:110: readLoop 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:132: handleOutput 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:142: Close 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:160: Wait 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:167: SetTerminalSize 80.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:190: ForceRedraw 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:198: SendBytes 88.9% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:211: SendMeta 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:215: IsRunning 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:221: GetReplayBuffer 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:225: GetScreenSnapshot 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:233: UpdateConnector 80.0% github.com/rcarmo/webterm-go-port/webterm/twoway.go:14: NewTwoWayMap 100.0% github.com/rcarmo/webterm-go-port/webterm/twoway.go:21: Set 88.9% github.com/rcarmo/webterm-go-port/webterm/twoway.go:36: DeleteKey 100.0% github.com/rcarmo/webterm-go-port/webterm/twoway.go:45: Get 100.0% github.com/rcarmo/webterm-go-port/webterm/twoway.go:52: GetKey 100.0% github.com/rcarmo/webterm-go-port/webterm/twoway.go:59: Keys 100.0% github.com/rcarmo/webterm-go-port/webterm/twoway.go:70: UnsafeForward 100.0% total: (statements) 81.0% - cd go && go test -race ./... ok github.com/rcarmo/webterm-go-port/cmd/webterm (cached) ok github.com/rcarmo/webterm-go-port/internal/terminalstate (cached) ok github.com/rcarmo/webterm-go-port/webterm (cached) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+42
-237
@@ -1,257 +1,62 @@
|
||||
# Architecture
|
||||
|
||||
This document describes the internal architecture of webterm.
|
||||
|
||||
## Overview
|
||||
|
||||
webterm is a web-based terminal server that exposes terminal sessions over HTTP and WebSocket. It's designed to run behind a reverse proxy with authentication.
|
||||
|
||||
> **Note:** As of v1.0.0, this project uses [ghostty-web](https://github.com/rcarmo/ghostty-web) (a patched fork with native theme support) instead of xterm.js.
|
||||
`webterm` is a Go HTTP/WebSocket server that hosts one or more terminal sessions and renders screenshot/telemetry surfaces for a dashboard UI.
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────────────────────────────────────────┐
|
||||
│ Browser │─────▶│ local_server.py │
|
||||
│ (ghostty- │◀─────│ (aiohttp web server) │
|
||||
│ web) │ │ │
|
||||
└─────────────┘ │ ┌──────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ │ session_ │ │ terminal_session.py │ │
|
||||
│ WebSocket │ │ manager.py │──│ (PTY + pyte emulator) │ │
|
||||
└────────────▶│ └──────────────┘ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ poller.py │ │ docker_stats.py │ │
|
||||
│ │ (I/O thread) │ │ (CPU metrics via socket) │ │
|
||||
│ └──────────────┘ └──────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
Browser (terminal.js + ghostty-vt.wasm)
|
||||
│
|
||||
│ WS / HTTP / SSE
|
||||
▼
|
||||
go/webterm/server.go (LocalServer)
|
||||
│
|
||||
├── session_manager.go (route/app/session registry)
|
||||
├── terminal_session.go (PTY-backed local sessions)
|
||||
├── docker_exec_session.go (Docker exec-backed sessions)
|
||||
├── docker_watcher.go (container add/remove discovery)
|
||||
├── docker_stats.go (CPU sampling + sparkline data)
|
||||
└── svg_exporter.go (terminal snapshot -> SVG)
|
||||
```
|
||||
|
||||
## Core Components
|
||||
## Packages
|
||||
|
||||
### local_server.py
|
||||
- `go/cmd/webterm`: CLI entrypoint
|
||||
- `go/webterm`: server/runtime/domain logic
|
||||
- `go/internal/terminalstate`: Go terminal emulator wrapper (`go-te`) used for screenshots
|
||||
|
||||
The main HTTP/WebSocket server built on aiohttp. Handles:
|
||||
## Runtime data flow
|
||||
|
||||
- **HTTP routes**: Dashboard, screenshots, sparklines, SSE events, health checks
|
||||
- **WebSocket connections**: Terminal I/O multiplexing with JSON protocol
|
||||
- **Screenshot caching**: Time-based and change-based cache invalidation
|
||||
- **SSE broadcasting**: Real-time activity notifications to dashboard
|
||||
1. Browser connects to `/ws/{route_key}`.
|
||||
2. `SessionManager` resolves or creates a session.
|
||||
3. Session reads PTY output and updates:
|
||||
- live WS stream (`stdout`)
|
||||
- replay buffer (reconnect support)
|
||||
- terminal-state tracker (`go-te`) for screenshots
|
||||
4. Dashboard pulls `/screenshot.svg` and listens on `/events` for activity.
|
||||
|
||||
Key classes:
|
||||
- `Server`: Main server class managing routes and session lifecycle
|
||||
## Static assets
|
||||
|
||||
### session_manager.py
|
||||
Assets live in `go/webterm/static`:
|
||||
|
||||
Manages the mapping between route keys and sessions:
|
||||
- `js/terminal.ts` source
|
||||
- `js/terminal.js` bundled client
|
||||
- `js/ghostty-vt.wasm`
|
||||
- `monospace.css`, icons, `manifest.json`
|
||||
|
||||
- **TwoWayDict**: Bidirectional mapping of RouteKey ↔ SessionID
|
||||
- **Session creation**: Creates TerminalSession on demand
|
||||
- **App registry**: Stores terminal configurations from manifest files
|
||||
The server resolves static files from:
|
||||
|
||||
### terminal_session.py
|
||||
1. `WEBTERM_STATIC_PATH` (if set)
|
||||
2. local repository-relative fallbacks rooted at `go/webterm/static`
|
||||
|
||||
Manages a single terminal session:
|
||||
## Docker integration
|
||||
|
||||
- **PTY management**: Fork/exec with pseudo-terminal
|
||||
- **pyte emulator**: Uses `AltScreen` (patched pyte) for ANSI interpretation
|
||||
- **Data pipeline**: C1 normalization → `expand_clear_sequences()` → `stream.feed()`
|
||||
- **Replay buffer**: 64KB ring buffer for reconnection support
|
||||
- **Resize handling**: Propagates window size changes to PTY
|
||||
- **Compose mode** loads services from a compose manifest and creates tiles for services carrying `webterm-command`.
|
||||
- **Watch mode** subscribes to Docker events and adds/removes tiles at runtime.
|
||||
- `webterm-theme` controls tile theme; default theme applies if unset.
|
||||
|
||||
The pyte screen buffer provides character-level access for screenshots.
|
||||
## Reliability notes
|
||||
|
||||
### poller.py
|
||||
|
||||
Background thread for non-blocking PTY I/O:
|
||||
|
||||
- **selector-based**: Uses `selectors.DefaultSelector` for efficient I/O
|
||||
- **Async queues**: Bridges sync I/O thread to async main loop
|
||||
- **Write queuing**: Handles backpressure for terminal input
|
||||
|
||||
### svg_exporter.py
|
||||
|
||||
Custom SVG renderer for terminal screenshots:
|
||||
|
||||
- **Per-character positioning**: Each character has explicit x coordinate
|
||||
- **Box-drawing scaling**: Vertical 1.2x scale for line-height alignment
|
||||
- **Color handling**: ANSI 16-color palette + 256-color + truecolor
|
||||
- **Wide character support**: Proper column tracking for CJK characters
|
||||
|
||||
### docker_stats.py
|
||||
|
||||
Collects CPU metrics from Docker containers:
|
||||
|
||||
- **Unix socket client**: Direct HTTP-over-Unix-socket to Docker API
|
||||
- **Compose awareness**: Filters containers by compose project label
|
||||
- **History buffer**: 180 samples (30 min at 10s intervals)
|
||||
- **Sparkline SVG**: Renders mini CPU graphs
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Terminal I/O
|
||||
|
||||
```
|
||||
Browser Server PTY
|
||||
│ │ │
|
||||
│──["stdin", "ls\n"]──────▶│ │
|
||||
│ │────write(b"ls\n")───────▶│
|
||||
│ │ │
|
||||
│ │◀───read(output)──────────│
|
||||
│◀─["stdout", "..."]───────│ │
|
||||
```
|
||||
|
||||
### Screenshot Generation
|
||||
|
||||
```
|
||||
Dashboard ──GET /screenshot.svg──▶ Server
|
||||
│
|
||||
▼
|
||||
TerminalSession
|
||||
│
|
||||
get_screen_state()
|
||||
│
|
||||
▼
|
||||
pyte.Screen
|
||||
.buffer
|
||||
│
|
||||
▼
|
||||
svg_exporter.py
|
||||
render_terminal_svg()
|
||||
│
|
||||
▼
|
||||
<svg>...</svg>
|
||||
```
|
||||
|
||||
### SSE Activity Updates
|
||||
|
||||
```
|
||||
Dashboard ──GET /events──▶ Server (SSE connection held open)
|
||||
│
|
||||
Terminal activity ───────────▶│
|
||||
│
|
||||
▼
|
||||
Broadcast to all SSE clients:
|
||||
data: {"route_key": "...", "type": "activity"}
|
||||
```
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
1. **Browser connects** to `/ws/{route_key}`
|
||||
2. **SessionManager** looks up or creates session for route_key
|
||||
3. **TerminalSession** forks PTY process, initializes pyte emulator
|
||||
4. **Poller** registers PTY fd for I/O events
|
||||
5. **WebSocket handler** bridges browser ↔ PTY via JSON messages
|
||||
6. **On disconnect**: Session stays alive; browser can reconnect
|
||||
7. **On reconnect**: Replay buffer restores recent output
|
||||
|
||||
## Configuration
|
||||
|
||||
### Manifest-based (--landing-manifest)
|
||||
|
||||
```yaml
|
||||
- name: Display Name
|
||||
slug: route-key
|
||||
command: /path/to/command
|
||||
```
|
||||
|
||||
### Compose-based (--compose-manifest)
|
||||
|
||||
Reads docker-compose.yaml, creates tiles for services with `webterm-command` label:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
myservice:
|
||||
labels:
|
||||
webterm-command: docker exec -it myservice bash
|
||||
```
|
||||
|
||||
## WebSocket Protocol
|
||||
|
||||
JSON-encoded messages over WebSocket:
|
||||
|
||||
| Direction | Message | Description |
|
||||
|-----------|---------|-------------|
|
||||
| Client→Server | `["stdin", "data"]` | Terminal input |
|
||||
| Client→Server | `["resize", {"width": N, "height": M}]` | Window resize |
|
||||
| Client→Server | `["ping", data]` | Keep-alive |
|
||||
| Server→Client | `["stdout", "data"]` | Terminal output |
|
||||
| Server→Client | `["pong", data]` | Keep-alive response |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/` | GET | Dashboard HTML or terminal redirect |
|
||||
| `/ws/{route_key}` | WS | WebSocket terminal connection |
|
||||
| `/screenshot.svg` | GET | SVG screenshot (query: `route_key`) |
|
||||
| `/cpu-sparkline.svg` | GET | CPU sparkline (query: `container`) |
|
||||
| `/events` | GET | SSE stream for activity updates |
|
||||
| `/health` | GET | Health check |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Why bundle ghostty-web directly?
|
||||
|
||||
We bundle a [patched version of ghostty-web](https://github.com/rcarmo/ghostty-web) for:
|
||||
|
||||
- **Native theme support** - Theme colors are passed directly to WASM, no runtime remapping needed
|
||||
- **Production-tested VT100 parser** - Uses Ghostty's battle-tested parser via WebAssembly
|
||||
- **Full configuration control** - fontFamily, scrollback, theme are configurable via CLI
|
||||
- **IME support** - Proper input method support for CJK languages
|
||||
- **Mobile support** - Custom keyboard handling for iOS Safari and Android
|
||||
- **Smaller bundle** - ~0.67 MB (down from 1.16 MB with color patching)
|
||||
- **11 built-in themes** - xterm, monokai, ristretto, dark, light, dracula, catppuccin, nord, gruvbox, solarized, tokyo
|
||||
|
||||
The pre-built `terminal.js` bundle is committed to the repo so users can `pip install` without needing Node.js/Bun.
|
||||
|
||||
### Why custom SVG exporter?
|
||||
|
||||
Rich's `export_svg()` had alignment issues with box-drawing characters and varied font rendering across browsers. The custom exporter:
|
||||
|
||||
- Positions each character individually for pixel-perfect alignment
|
||||
- Scales box-drawing characters vertically to fill line height
|
||||
- Uses explicit x coordinates instead of relying on font metrics
|
||||
|
||||
### Why pyte?
|
||||
|
||||
pyte provides a pure-Python terminal emulator that tracks screen state character-by-character, enabling:
|
||||
|
||||
- Screenshot generation without screen scraping
|
||||
- Dirty tracking for efficient cache invalidation
|
||||
- Full ANSI/VT100 escape sequence support
|
||||
|
||||
pyte 0.8.2 has gaps (no alternate screen buffer, no CSI S/T scroll), so `AltScreen` in `alt_screen.py` monkeypatches pyte at import time to fill them — see [pyte-patches.md](pyte-patches.md) for details.
|
||||
|
||||
### Why session persistence?
|
||||
|
||||
Unlike traditional web terminals, sessions survive page refreshes:
|
||||
|
||||
- Replay buffer allows catching up on missed output
|
||||
- SessionManager keeps sessions alive until explicit close
|
||||
- Enables dashboard with multiple live terminal thumbnails
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/webterm/
|
||||
├── alt_screen.py # pyte Screen subclass with alt buffer + SU/SD patches
|
||||
├── cli.py # Click CLI entry point
|
||||
├── config.py # Configuration parsing (YAML manifests)
|
||||
├── local_server.py # Main HTTP/WebSocket server
|
||||
├── session_manager.py # Session registry and routing
|
||||
├── session.py # Abstract session interface
|
||||
├── terminal_session.py # PTY-based terminal session
|
||||
├── poller.py # Async I/O polling thread
|
||||
├── svg_exporter.py # Terminal→SVG renderer
|
||||
├── docker_stats.py # Docker CPU metrics collector
|
||||
├── exit_poller.py # Graceful shutdown handling
|
||||
├── identity.py # Session ID generation
|
||||
├── slugify.py # URL-safe slug generation
|
||||
├── types.py # Type aliases
|
||||
├── constants.py # Platform constants
|
||||
└── static/
|
||||
├── monospace.css # Font stack CSS variables
|
||||
└── js/
|
||||
├── terminal.ts # ghostty-web client source (TypeScript)
|
||||
├── terminal.js # Pre-built bundle (committed)
|
||||
└── ghostty-vt.wasm # Ghostty VT100 parser (WebAssembly)
|
||||
```
|
||||
- WebSocket writes are serialized through a sender queue.
|
||||
- Session-manager maps are lock-protected and race-tested.
|
||||
- Replay buffers are bounded to avoid unbounded memory growth.
|
||||
|
||||
Reference in New Issue
Block a user