← 全部工具

cplieger/web-terminal-engine

热度 75 更新于 网络与系统管理

Headless VT100/ANSI terminal emulator + session engine (Go) with a browser renderer (TypeScript)

githubauto-collected

安装

暂未验证可直接使用的安装命令,请查看项目官方文档或 Release。

web-terminal-engine

Cross-language terminal emulator and session engine (Go) with browser renderer (TypeScript).

A standalone library that bridges a PTY to a browser WebSocket. The Go packages provide a VT100/VT500 screen buffer with SGR support and a WebSocket-based terminal session handler with reconnect, scrollback replay, and adaptive ping. The TypeScript package provides the browser-side renderer, keyboard mapper, mouse encoder, and binary wire decoder. No app-specific dependencies; only the standard library, github.com/coder/websocket, github.com/creack/pty, golang.org/x/sys, and github.com/cplieger/runesafe/v2.

Install

  • Go: go get github.com/cplieger/web-terminal-engine/v6@latest
  • TS: npx jsr add @cplieger/web-terminal-engine or npm i @cplieger/web-terminal-engine

Usage

import (
    "log/slog"
    "net/http"

    "github.com/cplieger/web-terminal-engine/v6/terminal"
)

h := terminal.NewHandler(
    []string{"/bin/bash"},
    terminal.WithWorkDir("/home/user"),
    terminal.WithLogger(slog.Default()),
)

h.RegisterRoutes(mux) // or use h as an http.Handler directly: // mux.Handle("/ws", h)

import { render, keyboard, mouse, decodeWireBinary } from "@cplieger/web-terminal-engine";

render.init({ output: document.getElementById("term-output")!, termWrap: document.getElementById("term")!, }); // On WebSocket binary message: const msg = decodeWireBinary(event.data); if (msg?.type === "screen") render.handleScreen(msg);


## API

### Go packages

- **`vt`**: VT100/VT500 screen buffer. `New(rows, cols)`, `Write([]byte)`, `Resize(rows, cols)`, `RenderRowWire(y)`, `DrainScrollback()`, `CursorPos()`, `HoldFlush()`, `ReleaseFlush()`, `IsFlushHeld()`, `RenderViewport()`, `RowString(y)`; atomic one-shot event drains `TakeResponse()`, `TakeClipboard()`, `TakeBell()`, `TakeScrollbackCleared()`, `TakePaletteChanged()`. Public fields: `Cells`, `Width`, `Height`, `Title`, `MouseMode`, `InAltScreen`, cursor/mode state.
- **`terminal`**: WebSocket session handler. `NewHandler(command, ...Option)`, `RegisterRoutes(mux)`, `ServeHTTP(w, r)`, and the shutdown pair `Close()` / `Shutdown(ctx) error`. `Close` ends the session and returns at once, leaving the cgroup teardown, the `/proc` sweep and the client notification in flight, while `Shutdown` does the same and then waits for all of it, returning `ctx.Err()` if the budget expires first. Use `Close` from a request handler or a timer, and `Shutdown` when the process is about to stop, because a process that exits underneath an unfinished teardown loses it. `SessionManager` exposes only the blocking form (`Shutdown(ctx) error`), which signals every session before waiting on any of them so their teardown windows overlap rather than sum, and reports how many were still unfinished when a budget expired; a manager is single-use, since `Shutdown` cancels the status sweep and the idle reaper and restarts neither. Options `WithWorkDir`, `WithLogger`, `WithEnv`, `WithScrollbackCapacity`, `WithOriginPolicy`, `WithOnProcessExit`, `WithKeepUnfocused`, `WithTheme`, `WithMinimumContrast` (a WCAG contrast floor between a run's text and its background, clamped 1..21 and off at 1 by default; see [Colors](#colors)), `WithCommandLogValue` (the process-start line's `command` attribute, the engine's only argv-bearing log site, records the given fixed marker, e.g. `"[redacted]"`, instead of the child argv, for consumers whose argv embeds operator-supplied values that could carry a credential; empty is ignored, keeping the default argv logging). Two release/logging helpers round out the surface: `LogID(id)` truncates a session id to a correlation-safe prefix plus an ellipsis: the first 8 bytes, cut back to a rune boundary, since a client-supplied resume id is arbitrary bytes and a blind byte prefix would put invalid UTF-8 into the log stream. The session id is a WS resume capability token, so every consumer that logs one must pass it through here rather than re-deriving the truncation (CWE-532); for the same reason the engine sets `Cache-Control: no-store` across the whole session surface: the session JSON bodies and the SSE stream carry an id in full, and the REST surface states the policy on _every_ response it produces, including the ones no handler writes, its mux's 404, 405 and path-cleaning redirect, whose URLs carry the id as a path segment even though their bodies do not. A consumer inherits the cache half of that protection instead of bolting on its own middleware; a `Cache-Control` a consumer's own outer middleware already set is preserved, which is both the way to state a stricter policy and the escape hatch for deliberately caching one of these routes (the JSON bodies are the exception and always `no-store`, since the id is in the body). `WirePairIncompatibility(WirePair{Server, Client})` is the peer-less build-time form of the wire-compatibility decision the runtime already makes twice, so a consumer's release gate can refuse a mismatched Go/TS pair before the image ships instead of discovering it as a close-4002 at first connect (each `WireEnd` labels one half's revision and minimum-peer floor, and `WirePair` labels which half is which; returns "" when compatible, else a reason; both cross-side floors exclusive; a non-positive input is a caller error rather than runtime's tolerated version-silent 0; and a half whose own minimum-peer floor exceeds its own revision is reported as self-inconsistent (corrupt or mis-extracted input) BEFORE any cross-side skew verdict, so a garbage pair never yields a confident "bump your pin" diagnosis). `(*Handler).ScrollbackBounds()` returns the session's retained-history bounds as an atomic pair, the committed index (one past the newest committed line) and the oldest still-replayable index, so a consumer can observe which absolute indices a resume can still ask for without decoding a wire frame; read-only, since bounds follow the child's output and the configured capacity. Handles PTY lifecycle, binary wire protocol, reconnect with scrollback replay, adaptive ping. `SessionManager` (`NewSessionManager`) fronts N PTY-backed sessions with `WebSocketHandler()` (`/ws?session=<id>`), `RESTHandler()` (`/api/sessions`, plus `PUT`/`DELETE /api/sessions/{id}/pinned-title` to set and clear a session's user-chosen name), and `EventsHandler()` (SSE `/api/sessions/events`); `PUT /api/sessions/order` sets the display order every viewer of a server shares, by sending every live session id in the wanted order (refused with 409 unless the list names the live set exactly, which is what makes the write atomic and a stale caller's view detectable); each session's position comes back as `order` on both the list and the status stream, so a reorder made in one browser moves the tab in every other one. `GET` and `PUT /api/sessions/layout` read and set the pane layout every viewer shares: which session each of the two panes shows (`left`, `right`, `null` for an empty pane), whether the split is `open`, the left pane's share of the row (`handle`, 0 to 1, fixed at 0.5 while closed) and the `selected` pane that receives typing, whose session is the active tab. A `PUT` is refused with 400 and the failed rule's text when the record is inconsistent (a closed split with a right pane or a right selection, one session on both sides, a selection resting on an empty pane while the other pane or any live session exists), and with 409 when a side names a session that is not live, which a client answers as it answers the order's 409. The server keeps the record valid on its own: the first session of an empty layout is shown on the left, a closing session leaves its pane with the selection moving to a shown pane and an emptied record refilled from the order, and reaping or shutdown resets it. The record is read once at load and never pushed on the status stream, so two viewers open at once diverge until one reloads, and the last writer's record is what a reload restores, as for the order. Both enumerations are served in that shared order, then oldest `createdAt`, then session id, so a client that builds a tab strip from whichever one reaches it first gets the same strip every time; status values working/idle/failed/warning/exited/crashed are derived server-side (working/failed/warning from the program's own OSC 9;4 progress report, read with iTerm2's state semantics; `exited` vs `crashed` splits an ordinary session end (status 0, or any exit the server itself caused, such as a closed session, the idle reaper or a shutdown) from a non-zero or signalled one, so a routine restart is never reported as a failure, and `(*Handler).ExitError()` exposes the retained `cmd.Wait()` error behind that decision), while input/done are latched from an OSC 9 notification through a pluggable classifier, and a latch is superseded by any later progress state that CONTRADICTS it: the active states 1/3 and the error state 2, but not the paused state 4, which asserts "stopped, resumable by the user" and so agrees with a needs-input latch rather than rivalling it; a consumer whose own sources show the wait ended with no progress change (kiro-cli's approval prompt notifies on the rising edge and stays silent on the falling one) reads the latch with `(*SessionManager).StatusLatch(id)`, which returns the latched status, the notification sequence that set it and whether the session is tracked, and clears it with `WithdrawStatusLatch(id, want, seq)`, a compare-and-swap that refuses an unknown session, a `want` outside `input`/`done`, a latch that no longer equals `want`, and a latch a newer notification re-set since `seq`, so a slow poller can never erase a fresh ask; the next sweep then recomputes the status from progress alone and emits it; each status event also carries the OSC 9;4 percentage as `progressValue` (-1 when the program reported none, which is not 0%) and delivers a fresh OSC 9 notification as `notification` + `notificationSeq`, so a consumer with no classifier installed still receives the message instead of it being dropped. Beside that turn status, the session list and the status stream also carry a SECONDARY activity a host reports through `WithSessionActivity(fn)`, for a background task that outlives the turn which started it: `activity` is `""` for none, else `working` (a background task is running), `waiting` (stopped and resumable, nobody is being asked) or `input` (a background task is blocked on the user), and `activityCount` is how many sources produced that state (>= 1 whenever it is non-empty). Both fields are always present, since `""` is not a legal state and so IS the absence. The engine has no concept of what produces one: the option supplies a getter the engine PULLS once per session on each status sweep, on each session list, and on each new status-stream subscriber's initial sync, so it runs on different goroutines and must be safe for concurrent use, must not block and must not perform I/O; unset (the default) leaves every session's activity empty. It stays out of the turn status entirely, never entering the status precedence, never touching the needs-input/done latch and never setting `reportsActivity` — a background task must not light the tab's turn indicator, which is the whole reason it is a second value rather than another status. Each session's `title` is RESOLVED server-side from four inputs in precedence order: the user's pinned name, the window title the program set via OSC 0/2, a client-derived automatic title a client asked the server to remember (`PUT /api/sessions/{id}/title`, e.g. the first line the user submitted), and the foreground process or working directory. Every attached client therefore shows the same label without re-deriving the ladder, and `pinnedTitle` travels alongside it so a client can tell a chosen name from an inferred one. When several clients share one session, a live resize is last-writer-wins and the shared screen relaxes to the smallest remaining client's size on disconnect. `MountSessionRoutes(mux, SessionHandlers{WS, REST, Events}, ...MountOption)` mounts exactly the route constants `WSPath`, `SessionsPath` + `SessionsSubtreePath` (the REST handler needs both mounts), and `SessionEventsPath`; the paths mirror the TS client's exported defaults, and additions are release-noted. `WithCreateGate(mw)` wraps the REST handler with caller-supplied middleware to rate-limit session creation (each POST forks a process); the mount states the `no-store` policy outside that gate, so a throttle refusal on a token-bearing path carries it too. `(*SessionManager).MountAPI(mux, opts...)` is the one-manager convenience.

  Spawned processes inherit the server's environment plus a default terminal identity: `TERM=xterm-256color`, `COLORTERM=truecolor`, `TERM_PROGRAM=iTerm.app`, and `TERM_PROGRAM_VERSION=3.6.6`, so apps detect truecolor, OSC 9;4 progress reporting, and DEC 2026 synchronized output. `WithEnv` values are appended after these defaults, so a consumer entry for the same variable overrides them.

  **Session reaping is on by default**, and it is the reason a closed tab does not leak its process tree. A PTY child that calls `setsid()` leaves both its process group and its session, so neither `kill(-pgid)` nor the PTY-close `SIGHUP` can reach it, and a process re-parented to init has no ancestry left to walk; agent runtimes do exactly this and some install no stdin-EOF exit path at all, so they outlive their session indefinitely holding hundreds of megabytes. The engine therefore spawns every session with one unguessable environment marker, which `execve` copies into every descendant and which survives both `setsid()` and re-parenting, and at session end it reclaims whatever still carries that marker: settle, `SIGTERM`, settle, `SIGKILL`, logging a single `session reap reclaimed escaped processes` line with `survivors`, `term_reclaimed`, `kill_forced` and `resident_bytes` only when something actually had to be reclaimed. This needs no capability, no cgroup, no mount and no PID namespace: measured reclaiming a `setsid()` escapee in 354ms inside an unprivileged container, where a full scan of 17,547 pids costs ~81ms. Two limits: a descendant that `execve`s with a deliberately scrubbed environment escapes the domain, and because the scan enumerates, a tree that forks during teardown can outrun one pass (each escalation rescans rather than reusing the first pass's pid set). Reaping is unconditional. `Containment` remains the stronger, opt-in boundary that nothing can escape, and it is now the only thing a cgroup buys: per-session `memory.peak`/`pids.peak`, plus a kill domain immune to both limits above.

StartZombieReaper(log, interval) is the separate, opt-in answer to a separate problem. Session reaping ends processes that are still ALIVE; this collects exit statuses nobody called wait() for. A server running as its container's PID 1 inherits every orphan in the container by re-parenting, and Go's os/exec waits only on the children it created, so every language server and every git a session forked becomes a permanent zombie parked on the server (measured: 17,323 zombies against 88 live processes). Wire it from the composition root of a server that is, or may become, PID 1; it installs PRSETCHILDSUBREAPER so orphans arrive even behind an init shim, then sweeps every interval (0 = 30s, floored at 1s) and returns a stop function. It is deliberately a periodic sweep rather than a SIGCHLD handler, because signal.Notify is process-global state and SIGCHLD is what the Go runtime itself uses to drive os/exec. It never waits on a pid the engine spawned: the registry that decides is written under a lock the spawn path holds across the fork, so a generic wait(-1) can never steal the head's status and turn a clean exit into an unknown one.

TypeScript (web/, published as @cplieger/web-terminal-engine on NPM and JSR)

  • render: DOM renderer driven by ScreenMessage / ScrollMessage frames. init, handleScreen, handleScroll, updateFontMetrics, computeSize, cellSize, gridSize, getCursorPx, setPredictedCursor, resetScreen, resetScrollback, getHighestIndex, getReplayBoundary, noteResumeBounds, updateReverseVideo.
  • keyboard: Translates KeyboardEvent to terminal byte sequences. mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal. Honors applicationCursor, applicationKeypad, bracketedPaste, and the kitty keyboard disambiguate fla