@cplieger/web-terminal-engine

中文文档
6.5.0发布于 2026/11/13MPL-2.0TypeScriptES Module零依赖构建来源已验证近 30 天下载 9393

Browser terminal engine: VT500 screen buffer, DOM renderer, and binary wire protocol

原始使用文档
文档目录9 节
  1. Install
  2. Usage
  3. Two engines on one page
  4. dispose()
  5. API
  6. Wire compatibility manifest
  7. Layout shapes table
  8. Browser-only
  9. License

@cplieger/web-terminal-engine

npm JSR

Browser virtual terminal renderer for the cplieger/web-terminal-engine Go module: DOM-based VT500 screen with OSC 8 hyperlink support, scrollback, keyboard mapper, mouse encoder, and binary wire decoder. Zero runtime dependencies.

The browser half of the web-terminal-engine cross-language terminal library. Pairs with the Go server-side packages (vt, terminal) over a versioned WebSocket protocol; see the project README for the full story.

Install

npx jsr add @cplieger/web-terminal-engine   # JSR (preferred)
npm i @cplieger/web-terminal-engine          # NPM

Usage

One engine per terminal. createTerminalEngine builds a renderer, a scroll controller, a mouse controller, a mode state and a connection over your two elements, routes screen, scroll and modes frames to the renderer for you, and returns the five parts plus one dispose():

import { createTerminalEngine, keyboard } from "@cplieger/web-terminal-engine";

const termWrap = document.getElementById("term") as HTMLElement; // the scroll container
const output = document.getElementById("term-output") as HTMLElement; // receives the rows

const engine = createTerminalEngine({
  output,
  termWrap,
  wsPath: "/ws",
  maxLines: 5000, // optional retained-line cap for the renderer's store
  callbacks: {
    onMessage: (msg) => {
      if (msg.type === "title") document.title = msg.title;
    },
    onOpen: () => {},
    onClose: () => {},
    onConnecting: () => {},
    onServerRestart: () => {},
    onProcessExit: () => {},
    computeSize: () => engine.renderer.computeSize(),
    initialSize: () => engine.renderer.computeSize(),
  },
});

document.fonts.ready.then(() => {
  engine.renderer.updateFontMetrics(); // measure the real font before announcing a size
  engine.connection.connect();
});

const onKeydown = (ev: KeyboardEvent): void => {
  const r = keyboard.mapKeyboardEvent(ev, engine.modes);
  if (r.kind === "send") {
    engine.connection.sendBinary(new TextEncoder().encode(r.bytes));
    ev.preventDefault();
  }
  if (r.kind === "scroll-up" || r.kind === "scroll-down") ev.preventDefault();
};
document.addEventListener("keydown", onKeydown);

// Called when the terminal leaves the page (a route change, a panel close):
function unmountTerminal(): void {
  document.removeEventListener("keydown", onKeydown);
  engine.dispose();
}

The five factories are also available on their own. Composing them by hand means passing every state dependency explicitly (there is no default instance to fall back to) and routing frames to the renderer yourself:

import {
  createConnection,
  createModeState,
  createMouseController,
  createRenderer,
  createScrollController,
} from "@cplieger/web-terminal-engine";

const modes = createModeState();
const scroll = createScrollController({
  scrollEl: termWrap,
  onScrollPosition: () => renderer.handleScrollPosition(),
});
const renderer = createRenderer({
  output,
  termWrap,
  scroll,
  modes,
  requestHistory: (from, max) => connection.requestHistory(from, max),
  historyBudget: () => connection.historyBudget(),
});
const mouse = createMouseController({
  modes,
  termElement: () => termWrap,
  gridElement: () => output, // the rows' own box: no padding, no scrollbar gutter
  cellSize: () => renderer.cellSize(),
  gridSize: () => renderer.gridSize(), // the RENDERED grid, not the size this client would like
  sendReport: (data) => connection.sendEphemeral(data),
});
const connection = createConnection({
  renderer,
  modes,
  mouse,
  callbacks: {
    onMessage: (msg) => {
      if (msg.type === "screen") renderer.handleScreen(msg);
      else if (msg.type === "scroll") renderer.handleScroll(msg);
    },
    onOpen: () => {},
    onClose: () => {},
    computeSize: () => renderer.computeSize(),
  },
});
connection.connect();

Two engines on one page

Two engines share nothing: each holds its own DOM handles, timers, listeners, socket and mode state, and the --char-w custom property is written on each engine's own termWrap, not on document.documentElement. The one thing two engines can collide on is the unmanaged session id, which lives in sessionStorage per browser tab: two engines that both run without a session manager (connect() with no setSession) and share the default sessionIdKey attach to the same session. Pass a distinct sessionIdKey to each ("vterm-session-id" and "vterm-session-id:2", say). A managed engine (setSession(id)) never mints that id and needs no key.

dispose()

engine.dispose() disposes the five parts in reverse build order: the connection closes its socket with code 1000 (no onClose fires), stops its heartbeat, history and reconnect timers, drops every session's queued bytes and detaches your callbacks; the mouse controller detaches its four listeners and cancels its pending motion frame; the renderer cancels its pending frame and blink interval, removes its visibilitychange listener, empties output, removes its overlays and the --char-w, cursor-blink-off and term-reverse-video marks from termWrap, and drops every element reference; the scroll controller removes its scroll listener; the mode state resets to POWER_ON_MODES. A callback may call dispose() on the engine that invoked it; the connection stops after the callback returns. Every method stays callable afterwards and answers an empty value rather than throwing:

PartMethodAfter dispose()
rendererboundStore()a new, empty LineStore on every call
renderergetHighestIndex(), getReplayBoundary()-1
rendererlastBrowseActivityMs(), browseCacheSize(), pendingRowCount()0
rendererreplayMaxForResume()5000
rendererapplyResumeTransition()false
renderercaptureViewMemory(), pendingRestoreAbs()null
renderercomputeSize(), gridSize(){ cols: 0, rows: 0 }
renderercellSize(){ width: 0, height: 0 }
renderergetCursorPx(){ left: 0, top: 0, cellH: 0 }
scrollisUserScrolledUp()false
scrollcurrentScrollTop()0
connectionsendBinary(), sendEphemeral(), requestHistory(), cancelPendingInput(), inputReady()false
connectionhistoryBudget(), pendingInputBytes()0
connectionlastAnnouncedSize()null
connectioncurrentSessionId()""
modesthe nine readers, snapshot()the POWER_ON_MODES values; applySnapshot and setKnown are no-ops, subscribe returns a no-op

createTerminalEngine and each factory throw when a listener or timer cannot be acquired, and a throw leaves nothing behind: the parts built before it are disposed in reverse order, so there is no partially built engine to dispose.

API

  • createTerminalEngine(options) builds the five parts below over output and termWrap and wires them together. It routes screen, scroll and modes frames to the renderer before your onMessage sees them. It returns a TerminalEngine, whose members are renderer, scroll, connection, mouse, modes and dispose. The options are callbacks, which is a ConnectionCallbacks, plus wsPath, sessionIdKey, maxLines, onCursorMove, onUserScrollChange and initialModes. It neither connects nor measures fonts. Call renderer.updateFontMetrics() when your font is ready and connection.connect() to start.
  • createRenderer(options) returns a Renderer, the DOM renderer driven by ScreenMessage / ScrollMessage frames over output inside termWrap, with an injected scroll controller and modes state. maxLines is the retained-line cap of its store. Memory-constrained consumers pass a smaller budget. History above the cap is evicted from the top in batches, and the live screen is never evicted, so a cap at or below the terminal height keeps the full screen with no scrollback. Its methods are handleScreen, handleScroll, handleScrollPosition, updateFontMetrics, computeSize, cellSize, gridSize, getCursorPx, setPredictedCursor, bind, boundStore, captureViewMemory, resetScreen, resetScrollback, getHighestIndex, getReplayBoundary, noteResumeBounds, handleGrid, clearGrid and dispose. getReplayBoundary() is the resume's haveThrough; getHighestIndex() also counts rows the application drew that the server has not committed, and sending it leaves a stale copy of the last screen in scrollback (the resume control). gridSize() answers the server screen's grid: the columns and rows of the current attachment's last grid frame. Before one arrives (a server that sends none, or a new socket whose frame has not come yet) it answers the measured columns and the last frame's rows. The connection calls clearGrid() as each socket starts, so a session switch never reads the previous session's grid. It subscribes to its modes, so the term-reverse-video class on termWrap always matches isReverseVideo().
  • keyboard translates KeyboardEvent to terminal byte sequences. mapKeyboardEvent(ev, modes), keyActionBeforeModes(ev) (what a caller does with a key at keydown before the modes are known: a key every mode state treats alike gets that action, an unmodified key some state sends is the terminal's, and a Ctrl, Alt or Meta chord some state leaves unsent stays the browser's), bracketPaste(text, modes) (the paste as a body a sender may cut short and a tail that must follow it), bracketTextForPaste(text, modes) (the two joined), isInterruptInput(text) (an unmodified Escape or a Ctrl+C as the toolbar and sticky Ctrl encode them), kittyDisambiguateActive(modes), prepareTextForTerminal, ctrlByteFor, plus the shared logical-key encodings plainCursorKeySeq and plainEscapeSeq that the toolbar module reuses. Every mode-dependent function takes the mode state as an argument, and an engine's modes satisfies it.
  • toolbar is the on-screen mobile toolbar widget. bindMobileToolbar({toolbar, send, ids?}) wires pointerdown handlers for an on-screen Ctrl/arrows/Tab/Enter/Esc toolbar, with sticky-Ctrl semantics and kitty/DECCKM-aware arrows byte-identical to the physical-key path. send receives each press as a KeyEncoder, send(encode), and encode(modes) answers the bytes under the mode state it is given, so a consumer encodes a press under the modes known when it sends it. It returns a MobileToolbarController exposing applyStickyCtrl, setCtrlArmed, isCtrlArmed and dispose. applyStickyCtrl(text) consumes an armed Ctrl when the text is typed and returns the encoder of its Ctrl chord, or null when the text goes out unchanged.
  • createMouseController(options) returns a MouseController, and encodeSGR is the SGR 1006 mouse encoder it uses, attached to termElement(). The options are a MouseInputHandler plus modes. The controller exposes resyncGesture, disarmGesture and dispose, and auto-gates on mouseMode > 0. MouseInputHandler carries two optional members, both defaulting to today's behaviour. gridElement names the element whose box IS the grid. The scroll container's own box includes its padding and scrollbar gutter, so it is not that box. gridSize supplies the dimensions of the grid ON SCREEN, from renderer.gridSize rather than the size this client measured. Every report is clamped into it, and rows are measured up from the bottom edge, because the screen window is the tail of the content. Its required sendReport(data) returns whether the report reached a live socket, so wire it to connection.sendEphemeral. Mouse input is best-effort, with four consequences. A release is emitted only for a press that was DELIVERED, because a release whose press never arrived is its own desync and the application never learned the button went down. A drag reports a held button only while the application believes that button is down, so motion for an undelivered gesture is dropped under mode 1002 and reports as no-button under 1003. resyncGesture() emits one release for any button the last reading says is now up, so a drag is not left pressed across a reconnect. The connection calls it once the resumeAck has declared the server's capabilities, rather than from onOpen. At onOpen the ephemeral channel is not yet known, so the release would take the reliable fallback. Motion is coalesced to one report per animation frame, latest wins, while press, release and wheel are never coalesced, because a wheel report is a notch count against a screen and coalescing loses scroll distance. resyncGesture shares one gate with the handlers, and each of them enforces it. A gesture stranded by the application turning tracking off is FORGOTTEN by whichever one next observes tracking off, because a release nothing asked for reaches the PTY as keystrokes. tmux answers the same way at attach, clearing mouse_drag_flag as it disables the modes. disarmGesture() forgets a held gesture without reporting anything. Call it when pointing one terminal's socket at another session, since the record belongs to the session that saw the press. Focus reporting is NOT here. It is transport state, and it lives in connection.setClientFocus.
  • createScrollController(options) returns a ScrollController, the auto-follow tracker for the scroll container scrollEl, with optional onUserScrollChange and onScrollPosition callbacks. Its methods are stickToBottom, scrollToBottom, scrollByPage, isUserScrolledUp, currentScrollTop, restoreView, adjustForContentShift and dispose, plus two renderer seams. noteContentShrink(scrollTopBefore) says a row removal rather than a gesture caused the scroll event about to arrive. reconcileScrollRange() moves the offset back inside the container's range when the container did not reconcile the shrink itself. The second seam exists because clamping an offset the content shrank out from under is an implementation behaviour and not a specified one. Blink and Gecko reconcile during layout, and WebKit does not. Without the correction, an iOS viewport is left parked past the end of the content after an application clears the screen. It then shows background with the content above it until the reader scrolls.
  • createModeState(initial?) returns a ModeState, and POWER_ON_MODES is its frozen default. The mode state is the DEC private mode state, one per terminal. The server's screen owns the modes. The connection writes them from the server's ModesMessage, and isKnown() is true only once the current socket's server has announced them: a connection marks them unknown when it is created, on every connect, close and teardown, and resets them to POWER_ON_MODES on setSession. Input encoded while they are unknown may use modes the program does not have, so a consumer hands mode-dependent input to the connection as a DeferredInput, which it encodes once isKnown() (the mouse controller sends no report meanwhile). Until then the readers answer power-on values after a switch and the previous socket's modes across a reconnect. POWER_ON_MODES equals what a fresh server screen announces. Its members are isBracketedPaste, isApplicationCursor, getMouseMode, isMouseSGR, isMousePixels, isFocusReporting, isApplicationKeypad, isReverseVideo, getKeyboardFlags, applySnapshot, snapshot, isKnown, setKnown, subscribe(cb) (called after every applySnapshot and every change of isKnown(), returning the unsubscribe) and dispose. POWER_ON_MODES is frozen, so copy it before changing a field, for example { ...POWER_ON_MODES, reverseVideo: true }.
  • decodeWireBinary(buf) is the top-level decoder for the binary WebSocket frames. It returns a ServerMessage or null for an invalid or truncated frame.
  • The wire compatibility constants WIRE_PROTOCOL_VERSION, MIN_SUPPORTED_SERVER_WIRE_VERSION, WIRE_INCOMPATIBLE_CLOSE_CODE and WIRE_COMPATIBILITY publish this client release's directional contract. The same values ship as a language-neutral JSON artifact for non-TypeScript consumers. See Wire compatibility manifest.
  • createConnection(options) returns a Connection, and MAX_REPLAY_LINES and MAX_OUTBOX_BYTES (the most input bytes in flight) are its exported bounds. It is the client-to-server WebSocket lifecycle. It owns the socket, exponential-backoff reconnect, and the resume/inputAck reliability layer, which is an ordered input queue plus server-restart detection. The options are the renderer it feeds, the modes it writes, the mouse it resyncs, your callbacks, and the optional wsPath (default "/ws") and sessionIdKey. Its methods are connect, sendBinary(input), cancelPendingInput, pendingInputBytes, holdInput, sendEphemeral(text), setClientFocus(focused), sendResize, lastAnnouncedSize, inputReady, reconnectNow, setSession, forgetSession, currentSessionId, historyBudget, requestHistory, disconnect and dispose. sendBinary accepts input of any size and delivers it in order: at most MAX_OUTBOX_BYTES are on the wire unacknowledged, the rest waits, and only a disposed connection refuses. The input is bytes, or a DeferredInput whose encode() the connection calls once the session's modes are known, so a key press or a bracketed paste follows the modes the server announced. Every input waits in its session's queue: it stays across a session switch, and a server restart drops it, encoded or not. No input goes on a socket before that socket has announced a size, so a fresh PTY never runs a command at its default size. holdInput() keeps the active session's queue off the wire until the release it returns runs. A deferred input marked paste can be cancelled by cancelPendingInput(): whole while it waits for its modes, and otherwise its body's unsent rest, while its tail (a bracketed paste's closing bracket) is still sent and every other entry is kept. sendResize announces the size computeSize() answers, skipping a repeat on the same socket; lastAnnouncedSize() is the last size this connection put on any socket (the open handler's initialSize included), so a consumer that holds a size back compares against what the server was told, not its own copy. inputReady() reads true from a socket's onInputReady (inside that callback too) until the socket closes, is replaced, ends or is torn down by forgetSession of the active session or disconnect, which fire no callback, so a consumer deciding at any moment whether to send now or queue reads it instead of tracking callbacks. Input sent while it reads true goes out at once, unless holdInput() holds it. sendEphemeral carries best-effort input such as mouse reports. It sends only when a socket is live right now and has announced its size, returns whether it did, and never touches the input queue or the byte counters. A report is therefore forgotten rather than replayed against a screen the resume has since repainted. Against a server that declares neither capability it falls back to sendBinary, because an unrecognized control is dropped post-latch and total silence is a worse failure than a stale report. On that path the report rides the reliable input queue and can be retransmitted after a reconnect. setClientFocus reports the terminal widget's focus. It sends the focus control when the server declared it derives the DEC 1004 answer, and otherwise drops the report. The server derives that answer from attachment state, every attached client's report, and its own terminal.WithKeepUnfocused. The client writes no focus bytes in either case. One client cannot answer for a session two devices may be attached to, and a client-written CSI I would override a keep-unfocused policy it cannot see. The next resumeAck re-asserts the current value once the capabilities are known. ConnectionCallbacks exposes onMessage(ServerMessage), onOpen/onClose/onConnecting/onPendingInput/onServerRestart/onProcessExit, onWireVersionMismatch, the definitive onWireIncompatible, onResumeBounds, a computeSize() provider, the optional getReplayMax and initialSize providers, and onInputReady(size), fired once per socket when its input starts, with the size it announced. That is after the socket's first size and its resume, so a server restart has already dropped the old process's input and fired onServerRestart, and input sent from onInputReady reaches the new process. Without initialSize, each socket announces computeSize() before its resume; an initialSize that answers null announces nothing, and that socket's input waits for the first sendResize. A member may call dispose() on the connection that invoked it. With onProcessExit wired, a definitive close (4001 process exited, 4004 unknown session) ends the session: no backoff reconnect, and reconnectNow() (the wake path) is refused until connect(), setSession() or forgetSession(). An explicit below-floor server revision or close code 4002 stops automatic reconnects until disconnect() or a page reload. Version-silent and future-revision servers remain tolerated. The connection decodes frames internally, applies modes frames to its modes, and answers the resume and history questions from its renderer. A consumer only needs to dispatch screen and scroll frames to the renderer, which createTerminalEngine does for you. Prefer this over wiring WebSocket plus decodeWireBinary by hand unless you need full control.
  • controlFrame(msg) and wsURL(proto, host, path?) are low-level helpers for the client-to-server protocol. controlFrame builds the 0x00-prefixed JSON control frames and wsURL builds the WebSocket URL. The connection uses them internally, and they are exported for advanced consumers.

Wire types (WireRun, ScreenMessage, ScrollMessage, ModesMessage, TitleMessage, ResumeAckMessage, ServerMessage, ControlMessage) are re-exported from the package root and match the Go server's wire format byte-for-byte.

Wire compatibility manifest

The same compatibility numbers are also published as a language-neutral JSON artifact, so a consumer that cannot import TypeScript — a Dockerfile, a shell release gate, a CI script in any language — can read them without scraping source:

# npm consumer
jq -r .wireCompatibility.protocolVersion \
  node_modules/@cplieger/web-terminal-engine/wire-compatibility.json

# JSR consumer (published as an included file; JSR exports are modules only)
curl -fsSL https://jsr.io/@cplieger/web-terminal-engine/<version>/wire-compatibility.json
{
  "schemaVersion": 1,
  "generatedBy": "web/src/test-helpers/wire-manifest.ts",
  "wireCompatibility": {
    "protocolVersion": 4,
    "minimumServerProtocolVersion": 3,
    "incompatibleCloseCode": 4002
  }
}

It is generated from WIRE_COMPATIBILITY (there is no second copy of the numbers), checked into the repo because the publish pipeline runs no build step, and guarded by a regenerate-and-diff test so it cannot go stale. Its values are pinned to both the TypeScript constants and the Go terminal constants, so the three surfaces cannot diverge.

What you may rely on (semver-governed public artifact, versioned by the package version):

  • The file is present at the package root of every npm tarball and JSR publish, at path wire-compatibility.json, and is importable from npm as @cplieger/web-terminal-engine/wire-compatibility.json.
  • schemaVersion is an integer describing this file's LAYOUT, not the wire protocol. Read it first and reject a value you do not understand.
  • Within a schemaVersion, the fields under wireCompatibility keep their names, types and meanings, and equal the correspondingly named WIRE_COMPATIBILITY members of the same release.
  • New fields may be added under either object in a MINOR release, so parse permissively (ignore unknown keys).

What is not part of the contract: generatedBy is an informational provenance note. The manifest deliberately carries no package version — the publish step injects that into package.json / jsr.json after checkout, so a version here would ship frozen at the repo placeholder; read the package version from those files.

Breaking changes to the manifest (MAJOR, and always release-noted): removing or renaming a field, changing a field's type or meaning, moving the file, or bumping schemaVersion. A change to the wire revision NUMBERS is not a manifest break — reporting them is what the file is for.

Layout shapes table

layout-shapes.json, at the package root beside the manifest and importable from npm as @cplieger/web-terminal-engine/layout-shapes.json, lists split-view layout records with whether the Go server accepts their shape. Each entry is { name, layout, valid }, where layout has the server's PaneLayout fields (left, right, selected, handle, open). The Go tests pin the server's rule to it, so a client that checks a layout before sending it can test its check against the same entries. It covers the shape only: the server also refuses a layout that names a session that is not live.

Browser-only

This package depends on document, HTMLElement, MessageChannel, and other DOM APIs, so it only runs in browser-like environments. The companion Go server runs anywhere Go does.

License

MPL-2.0. See LICENSE.

Third-party attributions are in THIRD_PARTY_NOTICES.md.

版本与发布记录

@cplieger/web-terminal-engine 共发布 70 个版本,首次发布于 2026/06/29;过去 12 个月发布了 70 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
6.5.0latest2026/11/13今天
6.4.12026/11/112 天前
6.5.0-dev.11dev2026/11/112 天前
6.5.0-dev.92026/11/094 天前
6.5.0-dev.62026/11/085 天前
6.5.0-dev.52026/11/067 天前

关于 @cplieger/web-terminal-engine 的常见问题

@cplieger/web-terminal-engine 是什么?

Browser terminal engine: VT500 screen buffer, DOM renderer, and binary wire protocol

如何安装 @cplieger/web-terminal-engine?

在项目目录运行 npm install @cplieger/web-terminal-engine,或使用 pnpm add / yarn add / bun add。

@cplieger/web-terminal-engine 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@cplieger/web-terminal-engine 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@cplieger/web-terminal-engine 可以商用吗?

@cplieger/web-terminal-engine 使用 MPL-2.0 许可。弱 Copyleft:可以商用,也可以和闭源代码一起使用;但如果分发时修改了 MPL 授权的文件,这些文件的修改需以 MPL 开源。以上为概括,不构成法律意见,请以许可原文为准。

@cplieger/web-terminal-engine 的下载量有多少?

根据 npm 官方下载统计,@cplieger/web-terminal-engine 在 2026/10/11 – 2026/11/09 这 30 天内被下载 9,393 次,日均约 313 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@cplieger/web-terminal-engine 还在维护吗?

最新版本 6.5.0 发布于 2026/11/13(今天);至今共发布 70 个版本,其中过去 12 个月发布了 70 个;首次发布于 2026/06/29。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。