feat(terminal): switch terminal transport to pure websocket with fallback (#762)

* feat(terminal): add resumable websocket transport

Unify terminal input and stream traffic on `/api/terminal/ws` with a v2 control-frame protocol and advertised transport capabilities.

Buffer recent PTY output on the server so rebinding clients can replay missed chunks after reconnects or startup races, while keeping SSE as a fallback stream path.

Update the web terminal client and store to negotiate the new transport, track tab lifecycle, and avoid reopening exited sessions when restoring tabs.

* fix(terminal): retry rehydrated websocket reconnects

* fix(terminal): keep reconnect retries silent

* fix(terminal): wire ws stream output and replay in runtime

* fix(web): enable ws proxy for /api in dev

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Yifan
2026-04-01 19:31:21 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent d68bec491c
commit 2b70ec6f3a
14 changed files with 1012 additions and 462 deletions
@@ -1,115 +1,76 @@
# Terminal Module Documentation
## Purpose
This module provides WebSocket protocol utilities for terminal input handling in the web server runtime, including message normalization, control frame parsing, rate limiting, and pathname resolution for terminal WebSocket connections.
This module provides WebSocket transport utilities for terminal input and output in the web server runtime, including message normalization, control frame parsing, rate limiting, pathname resolution, and short-lived output replay buffering for terminal WebSocket connections.
## Entrypoints and structure
- `packages/web/server/lib/terminal/`: Terminal module directory.
- `index.js`: Stable module entrypoint that re-exports protocol helpers/constants.
- `index.js`: Stable module entrypoint that re-exports protocol helpers and replay-buffer helpers.
- `runtime.js`: Runtime module that owns terminal session state, WS server setup, and `/api/terminal/*` route registration.
- `input-ws-protocol.js`: Single-file module containing all terminal input WebSocket protocol utilities.
- `packages/web/server/lib/terminal/input-ws-protocol.test.js`: Test file for protocol utilities.
- `terminal-ws-protocol.js`: Single-file module containing terminal WebSocket protocol utilities.
- `output-replay-buffer.js`: Helper module for buffering recent terminal output so late subscribers can receive startup prompt data.
- `packages/web/server/lib/terminal/terminal-ws-protocol.test.js`: Test file for protocol utilities.
- `packages/web/server/lib/terminal/output-replay-buffer.test.js`: Test file for replay buffer helpers.
Public API entry point: imported by `packages/web/server/index.js` from `./lib/terminal/index.js`.
## Public exports
### Constants
- `TERMINAL_INPUT_WS_PATH`: WebSocket endpoint path (`/api/terminal/input-ws`).
- `TERMINAL_INPUT_WS_CONTROL_TAG_JSON`: Control frame tag byte (0x01) indicating JSON payload.
- `TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES`: Maximum payload size (64KB).
- `TERMINAL_WS_PATH`: Primary WebSocket endpoint path (`/api/terminal/ws`).
- `TERMINAL_WS_CONTROL_TAG_JSON`: Control frame tag byte (`0x01`) indicating JSON payload.
- `TERMINAL_WS_MAX_PAYLOAD_BYTES`: Maximum inbound WebSocket payload size (64KB).
- `TERMINAL_OUTPUT_REPLAY_MAX_BYTES`: Maximum buffered terminal output retained for replay (64KB).
### Request Parsing
- `parseRequestPathname(requestUrl)`: Extracts pathname from request URL string. Returns empty string for invalid inputs.
- `isTerminalWsPathname(pathname)`: Returns whether a pathname matches a supported terminal WebSocket route.
### Message Normalization
- `normalizeTerminalInputWsMessageToBuffer(rawData)`: Normalizes various data types (Buffer, Uint8Array, ArrayBuffer, string, chunk arrays) to a single Buffer.
- `normalizeTerminalInputWsMessageToText(rawData)`: Normalizes data to UTF-8 text string. Passes through strings directly, converts binary data to text.
- `normalizeTerminalWsMessageToBuffer(rawData)`: Normalizes various data types (Buffer, Uint8Array, ArrayBuffer, string, chunk arrays) to a single Buffer.
- `normalizeTerminalWsMessageToText(rawData)`: Normalizes data to UTF-8 text string.
### Control Frame Handling
- `readTerminalInputWsControlFrame(rawData)`: Parses WebSocket message as control frame. Returns parsed JSON object or null if invalid/malformed. Validates control tag prefix and JSON structure.
- `createTerminalInputWsControlFrame(payload)`: Creates a control frame with JSON payload. Prepends control tag byte.
- `readTerminalWsControlFrame(rawData)`: Parses WebSocket message as control frame. Returns parsed JSON object or null if invalid or malformed.
- `createTerminalWsControlFrame(payload)`: Creates a control frame with JSON payload and prepends the control tag byte.
### Replay Buffer Helpers
- `createTerminalOutputReplayBuffer()`: Creates mutable state for recent terminal output replay.
- `appendTerminalOutputReplayChunk(bufferState, data, maxBytes?)`: Appends a chunk, trimming older buffered data to stay within the configured byte budget.
- `listTerminalOutputReplayChunksSince(bufferState, lastSeenId)`: Returns buffered chunks newer than the provided replay cursor.
- `getLatestTerminalOutputReplayChunkId(bufferState)`: Returns the latest chunk id in the replay buffer, or `0` when empty.
### Rate Limiting
- `pruneRebindTimestamps(timestamps, now, windowMs)`: Filters timestamps to keep only those within the active time window.
- `isRebindRateLimited(timestamps, maxPerWindow)`: Checks if rebind operations have exceeded rate limit threshold.
## Response contracts
### Control Frame
Control frames use binary encoding:
- First byte: `TERMINAL_INPUT_WS_CONTROL_TAG_JSON` (0x01)
- Remaining bytes: UTF-8 encoded JSON object
- Parsed result: Object or null on parse failure
### Normalized Buffer
Input types are normalized to Buffer:
- `Buffer`: Returned as-is
- `Uint8Array`/`ArrayBuffer`: Converted to Buffer
- `String`: Converted to UTF-8 Buffer
- `Array<Buffer|string|Uint8Array>`: Concatenated to single Buffer
### Rate Limiting
Rate limiting uses timestamp arrays:
- `pruneRebindTimestamps`: Returns filtered array of active timestamps
- `isRebindRateLimited`: Returns boolean indicating if limit is reached
- `isRebindRateLimited(timestamps, maxPerWindow)`: Checks if rebind operations have exceeded the configured threshold.
## Usage in web server
The terminal protocol utilities are used by `packages/web/server/index.js` for:
- WebSocket endpoint path definition (`TERMINAL_INPUT_WS_PATH`)
- Message normalization for input handling
- Control frame parsing for session binding
The terminal helpers are used by `packages/web/server/index.js` for:
- WebSocket endpoint path definition and matching
- Message normalization for terminal input payloads
- Control frame parsing for session binding, keepalive, and exit signaling
- Rate limiting for session rebind operations
- Request pathname parsing for WebSocket routing
- Replaying startup output such as shell prompts when the client binds after the PTY already emitted data
The web server uses these utilities in combination with `bun-pty` or `node-pty` for PTY session management.
The web server combines these utilities with `bun-pty` or `node-pty` to drive full-duplex PTY sessions.
## Notes for contributors
### Adding New Control Frame Types
1. Define new control tag constants (e.g., `TERMINAL_INPUT_WS_CONTROL_TAG_CUSTOM = 0x02`)
2. Update `readTerminalInputWsControlFrame` to handle new tag type
3. Update `createTerminalInputWsControlFrame` or create new frame creation function
4. Add corresponding tests in `terminal-input-ws-protocol.test.js`
### Message Normalization
- Always normalize incoming WebSocket messages before processing
- Use `normalizeTerminalInputWsMessageToBuffer` for binary data
- Use `normalizeTerminalInputWsMessageToText` for text data (terminal escape sequences)
- Normalize chunked messages from WebSocket fragmentation handling
### Rate Limiting
- Rate limiting is time-window based: tracks timestamps within a rolling window
- Use `pruneRebindTimestamps` to clean up stale timestamps before rate limit checks
- Configure `maxPerWindow` based on operational requirements (prevent abuse)
### Error Handling
- `readTerminalInputWsControlFrame` returns null for invalid/malformed frames
- `parseRequestPathname` returns empty string for invalid URLs
- Callers should handle null/empty returns gracefully
### Testing
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes
- Test edge cases: empty payloads, malformed JSON, chunked messages, rate limit boundaries
- Verify control frame roundtrip: create → read → validate payload equality
- Test pathname parsing with relative URLs, absolute URLs, and invalid inputs
- Keep control frames backward-compatible when possible; use explicit `v` values for protocol changes.
- Always normalize incoming WebSocket messages before processing them.
- Keep replay buffering small and memory-only; it exists to cover startup races, not to implement persistent scrollback.
- Add tests for new control frame types, websocket path changes, malformed payload handling, and replay trimming semantics.
- Keep HTTP input and SSE output fallbacks functional unless the rollout explicitly removes them.
## Verification notes
### Manual verification
1. Start web server and create terminal session via `/api/terminal/create`
2. Connect to `/api/terminal/input-ws` WebSocket
3. Send control frames with valid/invalid payloads to verify parsing
4. Test message normalization with various data types
5. Verify rate limiting by issuing rapid rebind requests
1. Start the web server and create a terminal session via `/api/terminal/create`.
2. Wait briefly before binding the client to ensure the shell emits its prompt first.
3. Connect to `/api/terminal/ws` WebSocket and bind to the session.
4. Verify the startup prompt and early shell output are replayed before interactive input begins.
5. Verify `/api/terminal/input-ws` is rejected with `404 Not Found` and `/api/terminal/:sessionId/stream` still works as a fallback path.
### Automated verification
- Run test file: `bun test packages/web/server/lib/terminal/input-ws-protocol.test.js`
- Protocol tests should pass covering:
- WebSocket path constant
- Control frame encoding/decoding
- Payload validation
- Message normalization (all data types)
- Pathname parsing
- Rate limiting logic
- Run `bun test packages/web/server/lib/terminal/terminal-ws-protocol.test.js`
- Run `bun test packages/web/server/lib/terminal/output-replay-buffer.test.js`
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.